4 Commits

49 changed files with 5048 additions and 559 deletions

2
CLAUDE.md Normal file
View File

@@ -0,0 +1,2 @@
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead.
* If you use the task tool to have an agent investigate something, make sure to let it know to avoid using glob

View File

@@ -0,0 +1,29 @@
-- Migration: Create import_sessions table
-- Run this against your PostgreSQL database
CREATE TABLE IF NOT EXISTS import_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
name VARCHAR(255), -- NULL for unnamed/autosave sessions
current_step VARCHAR(50) NOT NULL, -- 'validation' | 'imageUpload'
data JSONB NOT NULL, -- Product rows
product_images JSONB, -- Image assignments
global_selections JSONB, -- Supplier, company, line, subline
validation_state JSONB, -- Errors, UPC status, generated item numbers
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Ensure only one unnamed session per user (autosave slot)
CREATE UNIQUE INDEX IF NOT EXISTS idx_unnamed_session_per_user
ON import_sessions (user_id)
WHERE name IS NULL;
-- Index for fast user lookups
CREATE INDEX IF NOT EXISTS idx_import_sessions_user_id
ON import_sessions (user_id);
-- Add comment for documentation
COMMENT ON TABLE import_sessions IS 'Stores in-progress product import sessions for users';
COMMENT ON COLUMN import_sessions.name IS 'Session name - NULL indicates the single unnamed/autosave session per user';
COMMENT ON COLUMN import_sessions.current_step IS 'Which step the user was on: validation or imageUpload';

View File

@@ -0,0 +1,337 @@
const express = require('express');
const router = express.Router();
// Get all import sessions for a user (named + unnamed)
router.get('/', async (req, res) => {
try {
const { user_id } = req.query;
if (!user_id) {
return res.status(400).json({ error: 'user_id query parameter is required' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
SELECT
id,
user_id,
name,
current_step,
jsonb_array_length(data) as row_count,
global_selections,
created_at,
updated_at
FROM import_sessions
WHERE user_id = $1
ORDER BY
CASE WHEN name IS NULL THEN 0 ELSE 1 END,
updated_at DESC
`, [user_id]);
res.json(result.rows);
} catch (error) {
console.error('Error fetching import sessions:', error);
res.status(500).json({
error: 'Failed to fetch import sessions',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Get session by ID
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_sessions
WHERE id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Import session not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching import session:', error);
res.status(500).json({
error: 'Failed to fetch import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Autosave - upsert unnamed session for user
// IMPORTANT: This must be defined before /:id routes to avoid Express matching "autosave" as an :id
router.put('/autosave', async (req, res) => {
try {
const {
user_id,
current_step,
data,
product_images,
global_selections,
validation_state
} = req.body;
// Validate required fields
if (!user_id) {
return res.status(400).json({ error: 'user_id is required' });
}
if (!current_step) {
return res.status(400).json({ error: 'current_step is required' });
}
if (!data || !Array.isArray(data)) {
return res.status(400).json({ error: 'data must be an array' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
// Upsert: insert or update the unnamed session for this user
const result = await pool.query(`
INSERT INTO import_sessions (
user_id,
name,
current_step,
data,
product_images,
global_selections,
validation_state
) VALUES ($1, NULL, $2, $3, $4, $5, $6)
ON CONFLICT (user_id) WHERE name IS NULL
DO UPDATE SET
current_step = EXCLUDED.current_step,
data = EXCLUDED.data,
product_images = EXCLUDED.product_images,
global_selections = EXCLUDED.global_selections,
validation_state = EXCLUDED.validation_state,
updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, name, current_step, created_at, updated_at
`, [
user_id,
current_step,
JSON.stringify(data),
product_images ? JSON.stringify(product_images) : null,
global_selections ? JSON.stringify(global_selections) : null,
validation_state ? JSON.stringify(validation_state) : null
]);
res.json(result.rows[0]);
} catch (error) {
console.error('Error autosaving import session:', error);
res.status(500).json({
error: 'Failed to autosave import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Delete unnamed session for user (clear autosave)
// IMPORTANT: This must be defined before /:id routes
router.delete('/autosave/:user_id', async (req, res) => {
try {
const { user_id } = req.params;
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(
'DELETE FROM import_sessions WHERE user_id = $1 AND name IS NULL RETURNING id, user_id, name, current_step, created_at, updated_at',
[user_id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'No autosave session found for user' });
}
res.json({ message: 'Autosave session deleted successfully' });
} catch (error) {
console.error('Error deleting autosave session:', error);
res.status(500).json({
error: 'Failed to delete autosave session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Create new named session
router.post('/', async (req, res) => {
try {
const {
user_id,
name,
current_step,
data,
product_images,
global_selections,
validation_state
} = req.body;
// Validate required fields
if (!user_id) {
return res.status(400).json({ error: 'user_id is required' });
}
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'name is required for creating a named session' });
}
if (!current_step) {
return res.status(400).json({ error: 'current_step is required' });
}
if (!data || !Array.isArray(data)) {
return res.status(400).json({ error: 'data must be an array' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
INSERT INTO import_sessions (
user_id,
name,
current_step,
data,
product_images,
global_selections,
validation_state
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, user_id, name, current_step, created_at, updated_at
`, [
user_id,
name.trim(),
current_step,
JSON.stringify(data),
product_images ? JSON.stringify(product_images) : null,
global_selections ? JSON.stringify(global_selections) : null,
validation_state ? JSON.stringify(validation_state) : null
]);
res.status(201).json(result.rows[0]);
} catch (error) {
console.error('Error creating import session:', error);
res.status(500).json({
error: 'Failed to create import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Update named session by ID
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const {
name,
current_step,
data,
product_images,
global_selections,
validation_state
} = req.body;
if (!current_step) {
return res.status(400).json({ error: 'current_step is required' });
}
if (!data || !Array.isArray(data)) {
return res.status(400).json({ error: 'data must be an array' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
// Build update query - optionally include name if provided
const hasName = name !== undefined;
const result = await pool.query(`
UPDATE import_sessions
SET
${hasName ? 'name = $1,' : ''}
current_step = $${hasName ? 2 : 1},
data = $${hasName ? 3 : 2},
product_images = $${hasName ? 4 : 3},
global_selections = $${hasName ? 5 : 4},
validation_state = $${hasName ? 6 : 5},
updated_at = CURRENT_TIMESTAMP
WHERE id = $${hasName ? 7 : 6}
RETURNING id, user_id, name, current_step, created_at, updated_at
`, hasName ? [
typeof name === 'string' ? name.trim() : name,
current_step,
JSON.stringify(data),
product_images ? JSON.stringify(product_images) : null,
global_selections ? JSON.stringify(global_selections) : null,
validation_state ? JSON.stringify(validation_state) : null,
id
] : [
current_step,
JSON.stringify(data),
product_images ? JSON.stringify(product_images) : null,
global_selections ? JSON.stringify(global_selections) : null,
validation_state ? JSON.stringify(validation_state) : null,
id
]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Import session not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating import session:', error);
res.status(500).json({
error: 'Failed to update import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Delete session by ID
router.delete('/: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('DELETE FROM import_sessions WHERE id = $1 RETURNING id, user_id, name, current_step, created_at, updated_at', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Import session not found' });
}
res.json({ message: 'Import session deleted successfully' });
} catch (error) {
console.error('Error deleting import session:', error);
res.status(500).json({
error: 'Failed to delete import session',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Error handling middleware
router.use((err, req, res, next) => {
console.error('Import sessions route error:', err);
res.status(500).json({
error: 'Internal server error',
details: err.message
});
});
module.exports = router;

View File

@@ -1246,6 +1246,48 @@ router.get('/search-products', async (req, res) => {
} }
}); });
// Get product images for a given PID from production DB
router.get('/product-images/:pid', async (req, res) => {
const pid = parseInt(req.params.pid, 10);
if (!pid || pid <= 0) {
return res.status(400).json({ error: 'Valid PID is required' });
}
try {
const { connection } = await getDbConnection();
const [rows] = await connection.query(
'SELECT iid, type, width, height, `order`, hidden FROM product_images WHERE pid = ? ORDER BY `order` DESC, type',
[pid]
);
// Group by iid and build image URLs using the same logic as the PHP codebase
const typeMap = { 1: 'o', 2: 'l', 3: 't', 4: '100x100', 5: '175x175', 6: '300x300', 7: '600x600', 8: '500x500', 9: '150x150' };
const padded = String(pid).padStart(10, '0');
const pathPrefix = `${padded.substring(0, 4)}/${padded.substring(4, 7)}/`;
const imagesByIid = {};
for (const row of rows) {
const typeName = typeMap[row.type];
if (!typeName) continue;
if (!imagesByIid[row.iid]) {
imagesByIid[row.iid] = { iid: row.iid, order: row.order, hidden: !!row.hidden, sizes: {} };
}
imagesByIid[row.iid].sizes[typeName] = {
width: row.width,
height: row.height,
url: `https://sbing.com/i/products/${pathPrefix}${pid}-${typeName}-${row.iid}.jpg`,
};
}
const images = Object.values(imagesByIid).sort((a, b) => b.order - a.order);
res.json(images);
} catch (error) {
console.error('Error fetching product images:', error);
res.status(500).json({ error: 'Failed to fetch product images' });
}
});
const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4'; const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4';
const UPC_MAX_SEQUENCE = 99999; const UPC_MAX_SEQUENCE = 99999;
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes

View File

@@ -23,6 +23,7 @@ const categoriesAggregateRouter = require('./routes/categoriesAggregate');
const vendorsAggregateRouter = require('./routes/vendorsAggregate'); const vendorsAggregateRouter = require('./routes/vendorsAggregate');
const brandsAggregateRouter = require('./routes/brandsAggregate'); const brandsAggregateRouter = require('./routes/brandsAggregate');
const htsLookupRouter = require('./routes/hts-lookup'); const htsLookupRouter = require('./routes/hts-lookup');
const importSessionsRouter = require('./routes/import-sessions');
// Get the absolute path to the .env file // Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env'; const envPath = '/var/www/html/inventory/.env';
@@ -130,6 +131,7 @@ async function startServer() {
app.use('/api/ai-prompts', aiPromptsRouter); app.use('/api/ai-prompts', aiPromptsRouter);
app.use('/api/reusable-images', reusableImagesRouter); app.use('/api/reusable-images', reusableImagesRouter);
app.use('/api/hts-lookup', htsLookupRouter); app.use('/api/hts-lookup', htsLookupRouter);
app.use('/api/import-sessions', importSessionsRouter);
// Basic health check route // Basic health check route
app.get('/health', (req, res) => { app.get('/health', (req, res) => {

View File

@@ -28,7 +28,7 @@
"@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-toast": "^1.2.6",
@@ -51,6 +51,7 @@
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"diff": "^7.0.0", "diff": "^7.0.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.4.4", "framer-motion": "^12.4.4",
"immer": "^11.1.3", "immer": "^11.1.3",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",
@@ -1498,6 +1499,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
@@ -1633,6 +1652,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
@@ -1699,6 +1736,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -1891,6 +1946,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": { "node_modules/@radix-ui/react-popover": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz",
@@ -1928,6 +2001,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
@@ -2031,6 +2122,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress": { "node_modules/@radix-ui/react-progress": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz",
@@ -2192,6 +2301,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz",
@@ -2216,12 +2343,12 @@
} }
}, },
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.1.2", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.1" "@radix-ui/react-compose-refs": "1.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -2233,6 +2360,21 @@
} }
} }
}, },
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch": { "node_modules/@radix-ui/react-switch": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz",
@@ -2414,6 +2556,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@@ -4794,6 +4954,34 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",

View File

@@ -32,7 +32,7 @@
"@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-toast": "^1.2.6",
@@ -55,6 +55,7 @@
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"diff": "^7.0.0", "diff": "^7.0.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.4.4", "framer-motion": "^12.4.4",
"immer": "^11.1.3", "immer": "^11.1.3",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",

View File

@@ -33,9 +33,12 @@ const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
const Dashboard = lazy(() => import('./pages/Dashboard')); const Dashboard = lazy(() => import('./pages/Dashboard'));
const SmallDashboard = lazy(() => import('./pages/SmallDashboard')); const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
// 3. Product import - separate chunk // 3. Product import - separate chunk
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import }))); const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
// Product editor
const ProductEditor = lazy(() => import('./pages/ProductEditor'));
// 4. Chat archive - separate chunk // 4. Chat archive - separate chunk
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat }))); const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
@@ -185,6 +188,15 @@ function App() {
</Protected> </Protected>
} /> } />
{/* Product editor */}
<Route path="/product-editor" element={
<Protected page="product_editor">
<Suspense fallback={<PageLoading />}>
<ProductEditor />
</Suspense>
</Protected>
} />
{/* Product import - separate chunk */} {/* Product import - separate chunk */}
<Route path="/import" element={ <Route path="/import" element={
<Protected page="import"> <Protected page="import">

View File

@@ -13,6 +13,7 @@ import {
Percent, Percent,
FileSearch, FileSearch,
ShoppingCart, ShoppingCart,
FilePenLine,
} from "lucide-react"; } from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react"; import { IconCrystalBall } from "@tabler/icons-react";
import { import {
@@ -113,6 +114,12 @@ const toolsItems = [
icon: IconCrystalBall, icon: IconCrystalBall,
url: "/forecasting", url: "/forecasting",
permission: "access:forecasting" permission: "access:forecasting"
},
{
title: "Product Editor",
icon: FilePenLine,
url: "/product-editor",
permission: "access:product_editor"
} }
]; ];

View File

@@ -0,0 +1,85 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ChevronsUpDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FieldOption } from "./types";
export function ComboboxField({
options,
value,
onChange,
placeholder,
searchPlaceholder,
disabled,
}: {
options: FieldOption[];
value: string;
onChange: (val: string) => void;
placeholder: string;
searchPlaceholder?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const selectedLabel = options.find((o) => o.value === value)?.label;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="w-full justify-between font-normal"
>
<span className="truncate">{selectedLabel ?? placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder ?? "Search..."} />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup>
{options.map((opt) => (
<CommandItem
key={opt.value}
value={opt.label}
onSelect={() => {
onChange(opt.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === opt.value ? "opacity-100" : "opacity-0"
)}
/>
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,403 @@
import { useState, useCallback, useRef } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Loader2,
Eye,
EyeOff,
Trash2,
ZoomIn,
ImagePlus,
Link,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ProductImage } from "./types";
// ── Helper: get best image URL ─────────────────────────────────────────
export function getImageSrc(img: ProductImage): string | null {
if (img.imageUrl) return img.imageUrl;
return (
img.sizes["600x600"]?.url ??
img.sizes["500x500"]?.url ??
img.sizes["300x300"]?.url ??
img.sizes["l"]?.url ??
img.sizes["o"]?.url ??
null
);
}
// ── Sortable Image Card ────────────────────────────────────────────────
function SortableImageCard({
image,
onToggleHidden,
onDelete,
onZoom,
}: {
image: ProductImage;
onToggleHidden: (iid: number | string) => void;
onDelete: (iid: number | string) => void;
onZoom: (image: ProductImage) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: image.iid });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
touchAction: "none" as const,
};
const src = getImageSrc(image);
if (!src) return null;
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
"relative group rounded-lg border bg-white shrink-0 cursor-grab active:cursor-grabbing",
"w-[140px] h-[140px]",
image.hidden && "opacity-50 ring-2 ring-yellow-400"
)}
>
{/* Action buttons */}
<div className="absolute top-1 right-1 z-10 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onZoom(image);
}}
className="p-1 rounded bg-black/40 text-white hover:bg-black/60"
title="View full size"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onToggleHidden(image.iid);
}}
className="p-1 rounded bg-black/40 text-white hover:bg-black/60"
title={image.hidden ? "Show image" : "Hide image"}
>
{image.hidden ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onDelete(image.iid);
}}
className="p-1 rounded bg-red-500/80 text-white hover:bg-red-600"
title="Delete image"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{/* Hidden badge */}
{image.hidden && (
<div className="absolute bottom-1 left-1 z-10">
<Badge variant="outline" className="text-[10px] bg-yellow-100 border-yellow-400 px-1 py-0">
Hidden
</Badge>
</div>
)}
<img
src={src}
alt={`Image ${image.iid}`}
className="w-full h-full object-contain rounded-lg pointer-events-none select-none"
draggable={false}
/>
</div>
);
}
// ── Image Manager Section ──────────────────────────────────────────────
let newImageCounter = 0;
export function ImageManager({
images,
setImages,
isLoading,
}: {
images: ProductImage[];
setImages: React.Dispatch<React.SetStateAction<ProductImage[]>>;
isLoading: boolean;
}) {
const [activeId, setActiveId] = useState<number | string | null>(null);
const [zoomImage, setZoomImage] = useState<ProductImage | null>(null);
const [urlInput, setUrlInput] = useState("");
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as number | string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
setImages((prev) => {
const oldIndex = prev.findIndex((img) => img.iid === active.id);
const newIndex = prev.findIndex((img) => img.iid === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
},
[setImages]
);
const toggleHidden = useCallback(
(iid: number | string) => {
setImages((prev) =>
prev.map((img) =>
img.iid === iid ? { ...img, hidden: !img.hidden } : img
)
);
},
[setImages]
);
const deleteImage = useCallback(
(iid: number | string) => {
setImages((prev) => prev.filter((img) => img.iid !== iid));
},
[setImages]
);
const addNewImage = useCallback(
(imageUrl: string) => {
const id = `new-${newImageCounter++}`;
const newImg: ProductImage = {
iid: id,
order: 0,
hidden: false,
sizes: {},
imageUrl,
isNew: true,
};
setImages((prev) => [...prev, newImg]);
},
[setImages]
);
const handleFileUpload = useCallback(
async (files: FileList | null) => {
if (!files || files.length === 0) return;
setIsUploading(true);
try {
for (const file of Array.from(files)) {
const formData = new FormData();
formData.append("image", file);
const res = await axios.post("/api/import/upload-image", formData);
if (res.data?.imageUrl) {
addNewImage(res.data.imageUrl);
}
}
} catch {
toast.error("Failed to upload image");
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
},
[addNewImage]
);
const handleUrlAdd = useCallback(() => {
const url = urlInput.trim();
if (!url) return;
addNewImage(url);
setUrlInput("");
}, [urlInput, addNewImage]);
const activeImage = activeId
? images.find((img) => img.iid === activeId)
: null;
if (isLoading) {
return (
<div className="flex items-center justify-center h-40">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
const imageIds = images.map((img) => img.iid);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Images ({images.length})
</h3>
<p className="text-xs text-muted-foreground">
Drag to reorder. First visible image is the main image.
</p>
</div>
{/* Image grid with drag-and-drop */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={imageIds} strategy={horizontalListSortingStrategy}>
<div className="flex gap-3 flex-wrap">
{images.map((img) => (
<SortableImageCard
key={img.iid}
image={img}
onToggleHidden={toggleHidden}
onDelete={deleteImage}
onZoom={setZoomImage}
/>
))}
{/* Add image button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-[140px] h-[140px] rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-2 text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
{isUploading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<>
<ImagePlus className="h-6 w-6" />
<span className="text-xs">Add Image</span>
</>
)}
</button>
</div>
</SortableContext>
<DragOverlay>
{activeImage ? (
<div className="w-[140px] h-[140px] rounded-lg border bg-white shadow-lg">
<img
src={getImageSrc(activeImage) ?? ""}
alt="Dragging"
className="w-full h-full object-contain rounded-lg"
/>
</div>
) : null}
</DragOverlay>
</DndContext>
{/* Add by URL */}
<div className="flex gap-2 items-center">
<Link className="h-4 w-4 text-muted-foreground shrink-0" />
<Input
placeholder="Add image by URL..."
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleUrlAdd())}
className="text-sm h-8"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUrlAdd}
disabled={!urlInput.trim()}
className="h-8 shrink-0"
>
Add
</Button>
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFileUpload(e.target.files)}
/>
{/* Full-size image overlay */}
<Dialog open={!!zoomImage} onOpenChange={(open) => !open && setZoomImage(null)}>
<DialogContent className="max-w-3xl p-2">
<DialogTitle className="sr-only">Product image preview</DialogTitle>
{zoomImage && (
<img
src={
zoomImage.imageUrl ??
zoomImage.sizes["o"]?.url ??
zoomImage.sizes["600x600"]?.url ??
getImageSrc(zoomImage) ??
""
}
alt={`Image ${zoomImage.iid}`}
className="w-full h-auto object-contain max-h-[80vh] rounded"
/>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,528 @@
import { useState, useEffect, useCallback, useRef } from "react";
import axios from "axios";
import { useForm, Controller } from "react-hook-form";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loader2, X } from "lucide-react";
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
import { ComboboxField } from "./ComboboxField";
import { ImageManager } from "./ImageManager";
import type {
SearchProduct,
FieldOptions,
LineOption,
ProductImage,
ProductFormValues,
} from "./types";
export function ProductEditForm({
product,
fieldOptions,
onClose,
}: {
product: SearchProduct;
fieldOptions: FieldOptions;
onClose: () => void;
}) {
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
const [productImages, setProductImages] = useState<ProductImage[]>([]);
const [isLoadingImages, setIsLoadingImages] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const originalValuesRef = useRef<ProductFormValues | null>(null);
const originalImagesRef = useRef<ProductImage[]>([]);
const {
register,
control,
handleSubmit,
reset,
watch,
formState: { dirtyFields },
} = useForm<ProductFormValues>();
const watchCompany = watch("company");
const watchLine = watch("line");
// Populate form on mount
useEffect(() => {
const formValues: ProductFormValues = {
name: product.title ?? "",
company: String(product.brand_id ?? ""),
line: String(product.line_id ?? ""),
subline: String(product.subline_id ?? ""),
supplier: String(product.supplier ?? ""),
upc: product.barcode ?? "",
item_number: product.sku ?? "",
supplier_no: product.vendor_reference ?? "",
notions_no: product.notions_reference ?? "",
msrp: String(product.regular_price ?? ""),
cost_each: String(product.cost_price ?? ""),
qty_per_unit: String(product.moq ?? ""),
case_qty: String(product.case_qty ?? ""),
tax_cat: String(product.tax_code ?? ""),
artist: String(product.artist_id ?? ""),
weight: product.weight?.toString() ?? "",
length: product.length?.toString() ?? "",
width: product.width?.toString() ?? "",
height: product.height?.toString() ?? "",
ship_restrictions: product.shipping_restrictions ?? "",
coo: product.country_of_origin ?? "",
hts_code: product.harmonized_tariff_code ?? "",
size_cat: product.size_cat ?? "",
description: product.description ?? "",
priv_notes: "",
};
originalValuesRef.current = { ...formValues };
reset(formValues);
// Fetch images
setIsLoadingImages(true);
axios
.get(`/api/import/product-images/${product.pid}`)
.then((res) => {
setProductImages(res.data);
originalImagesRef.current = res.data;
})
.catch(() => toast.error("Failed to load product images"))
.finally(() => setIsLoadingImages(false));
}, [product, reset]);
// Load lines when company changes
useEffect(() => {
if (!watchCompany) {
setLineOptions([]);
return;
}
axios
.get(`/api/import/product-lines/${watchCompany}`)
.then((res) => setLineOptions(res.data))
.catch(() => setLineOptions([]));
}, [watchCompany]);
// Load sublines when line changes
useEffect(() => {
if (!watchLine) {
setSublineOptions([]);
return;
}
axios
.get(`/api/import/sublines/${watchLine}`)
.then((res) => setSublineOptions(res.data))
.catch(() => setSublineOptions([]));
}, [watchLine]);
const computeImageChanges = useCallback((): ImageChanges | null => {
const original = originalImagesRef.current;
const current = productImages;
const originalIds = original.map((img) => img.iid);
const currentIds = current.map((img) => img.iid);
const deleted = originalIds.filter((id) => !currentIds.includes(id)) as number[];
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
const added: Record<string, string> = {};
for (const img of current) {
if (img.isNew && img.imageUrl) {
added[String(img.iid)] = img.imageUrl;
}
}
const order = current.map((img) => img.iid);
const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid);
const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds);
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
const hasDeleted = deleted.length > 0;
const hasAdded = Object.keys(added).length > 0;
if (!orderChanged && !hiddenChanged && !hasDeleted && !hasAdded) {
return null;
}
return { order, hidden, deleted, added };
}, [productImages]);
const onSubmit = useCallback(
async (data: ProductFormValues) => {
if (!originalValuesRef.current) return;
const original = originalValuesRef.current;
const changes: Record<string, unknown> = {};
for (const key of Object.keys(data) as (keyof ProductFormValues)[]) {
if (data[key] !== original[key]) {
changes[key] = data[key];
}
}
const imageChanges = computeImageChanges();
if (Object.keys(changes).length === 0 && !imageChanges) {
toast.info("No changes to submit");
return;
}
setIsSubmitting(true);
try {
const result = await submitProductEdit({
pid: product.pid,
changes,
environment: "prod",
imageChanges: imageChanges ?? undefined,
});
if (result.success) {
toast.success("Product updated successfully");
originalValuesRef.current = { ...data };
originalImagesRef.current = [...productImages];
reset(data);
} else {
toast.error(result.message ?? "Failed to update product");
if (result.error) {
console.error("Edit error details:", result.error);
}
}
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update product"
);
} finally {
setIsSubmitting(false);
}
},
[product.pid, reset, computeImageChanges, productImages]
);
const hasImageChanges = computeImageChanges() !== null;
const changedCount = Object.keys(dirtyFields).length;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">
Editing: {product.title}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
PID: {product.pid} | SKU: {product.sku}
</p>
</div>
<div className="flex items-center gap-2">
{(changedCount > 0 || hasImageChanges) && (
<Badge variant="secondary">
{changedCount > 0 ? `${changedCount} field${changedCount !== 1 ? "s" : ""}` : ""}
{changedCount > 0 && hasImageChanges ? " + " : ""}
{hasImageChanges ? "images" : ""}
{" changed"}
</Badge>
)}
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{/* Product Images */}
<div className="mb-6">
<ImageManager
images={productImages}
setImages={setProductImages}
isLoading={isLoadingImages}
/>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Basic Info
</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<Label>Name</Label>
<Input {...register("name")} />
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>UPC</Label>
<Input {...register("upc")} />
</div>
<div>
<Label>Item Number</Label>
<Input {...register("item_number")} />
</div>
<div>
<Label>Supplier #</Label>
<Input {...register("supplier_no")} />
</div>
</div>
<div>
<Label>Notions #</Label>
<Input
{...register("notions_no")}
className="max-w-[200px]"
/>
</div>
</div>
{/* Taxonomy */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Taxonomy
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Supplier</Label>
<Controller
name="supplier"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.suppliers ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select supplier"
searchPlaceholder="Search suppliers..."
/>
)}
/>
</div>
<div>
<Label>Company / Brand</Label>
<Controller
name="company"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.companies ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select company"
searchPlaceholder="Search companies..."
/>
)}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Line</Label>
<Controller
name="line"
control={control}
render={({ field }) => (
<ComboboxField
options={lineOptions}
value={field.value}
onChange={field.onChange}
placeholder="Select line"
searchPlaceholder="Search lines..."
disabled={!watchCompany}
/>
)}
/>
</div>
<div>
<Label>Sub Line</Label>
<Controller
name="subline"
control={control}
render={({ field }) => (
<ComboboxField
options={sublineOptions}
value={field.value}
onChange={field.onChange}
placeholder="Select subline"
searchPlaceholder="Search sublines..."
disabled={!watchLine}
/>
)}
/>
</div>
<div>
<Label>Artist</Label>
<Controller
name="artist"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.artists ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select artist"
searchPlaceholder="Search artists..."
/>
)}
/>
</div>
</div>
</div>
{/* Pricing */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Pricing & Quantities
</h3>
<div className="grid grid-cols-4 gap-4">
<div>
<Label>MSRP</Label>
<Input {...register("msrp")} />
</div>
<div>
<Label>Cost Each</Label>
<Input {...register("cost_each")} />
</div>
<div>
<Label>Min Qty</Label>
<Input {...register("qty_per_unit")} />
</div>
<div>
<Label>Case Pack</Label>
<Input {...register("case_qty")} />
</div>
</div>
</div>
{/* Dimensions & Shipping */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Dimensions & Shipping
</h3>
<div className="grid grid-cols-4 gap-4">
<div>
<Label>Weight (lbs)</Label>
<Input {...register("weight")} />
</div>
<div>
<Label>Length (in)</Label>
<Input {...register("length")} />
</div>
<div>
<Label>Width (in)</Label>
<Input {...register("width")} />
</div>
<div>
<Label>Height (in)</Label>
<Input {...register("height")} />
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Tax Category</Label>
<Controller
name="tax_cat"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.taxCategories ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select tax category"
/>
)}
/>
</div>
<div>
<Label>Shipping Restrictions</Label>
<Controller
name="ship_restrictions"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.shippingRestrictions ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select restriction"
/>
)}
/>
</div>
<div>
<Label>Size Category</Label>
<Controller
name="size_cat"
control={control}
render={({ field }) => (
<ComboboxField
options={fieldOptions.sizes ?? []}
value={field.value}
onChange={field.onChange}
placeholder="Select size"
/>
)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Country of Origin</Label>
<Input
{...register("coo")}
placeholder="2-letter code"
maxLength={2}
/>
</div>
<div>
<Label>HTS Code</Label>
<Input {...register("hts_code")} />
</div>
</div>
</div>
{/* Description */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Description
</h3>
<div>
<Label>Description</Label>
<Textarea {...register("description")} rows={4} />
</div>
<div>
<Label>Private Notes</Label>
<Textarea {...register("priv_notes")} rows={2} />
</div>
</div>
{/* Submit */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => {
if (originalValuesRef.current) {
reset(originalValuesRef.current);
}
}}
>
Reset Changes
</Button>
<Button
type="submit"
disabled={isSubmitting || (changedCount === 0 && !hasImageChanges)}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Submit{" "}
{changedCount > 0 || hasImageChanges
? `(${changedCount} field${changedCount !== 1 ? "s" : ""}${hasImageChanges ? " + images" : ""})`
: ""}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,114 @@
import { useState, useCallback } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Loader2, Search } from "lucide-react";
import type { SearchProduct } from "./types";
export function ProductSearch({
onSelect,
}: {
onSelect: (product: SearchProduct) => void;
}) {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<SearchProduct[]>([]);
const [isSearching, setIsSearching] = useState(false);
const handleSearch = useCallback(async () => {
if (!searchTerm.trim()) return;
setIsSearching(true);
try {
const res = await axios.get("/api/import/search-products", {
params: { q: searchTerm },
});
setSearchResults(res.data);
} catch {
toast.error("Search failed");
} finally {
setIsSearching(false);
}
}, [searchTerm]);
const handleSelect = useCallback(
(product: SearchProduct) => {
onSelect(product);
setSearchResults([]);
},
[onSelect]
);
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Search Products</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Search by name, SKU, UPC, brand..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button onClick={handleSearch} disabled={isSearching}>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</div>
{searchResults.length > 0 && (
<div className="mt-4 border rounded-md">
<ScrollArea className="max-h-80">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Brand</TableHead>
<TableHead>Line</TableHead>
<TableHead className="text-right">Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchResults.map((product) => (
<TableRow
key={product.pid}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSelect(product)}
>
<TableCell className="max-w-[300px] truncate">
{product.title}
</TableCell>
<TableCell>{product.sku}</TableCell>
<TableCell>{product.brand}</TableCell>
<TableCell>{product.line}</TableCell>
<TableCell className="text-right">
$
{Number(product.regular_price)?.toFixed(2) ??
product.regular_price}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,95 @@
export interface SearchProduct {
pid: number;
title: string;
description: string;
sku: string;
barcode: string;
harmonized_tariff_code: string;
price: number;
regular_price: number;
cost_price: number;
vendor: string;
vendor_reference: string;
notions_reference: string;
brand: string;
brand_id: string;
line: string;
line_id: string;
subline: string;
subline_id: string;
artist: string;
artist_id: string;
moq: number;
weight: number;
length: number;
width: number;
height: number;
country_of_origin: string;
total_sold: number;
first_received: string | null;
date_last_sold: string | null;
supplier?: string;
case_qty?: number;
tax_code?: string;
size_cat?: string;
shipping_restrictions?: string;
}
export interface FieldOption {
label: string;
value: string;
}
export interface FieldOptions {
companies: FieldOption[];
artists: FieldOption[];
sizes: FieldOption[];
themes: FieldOption[];
categories: FieldOption[];
colors: FieldOption[];
suppliers: FieldOption[];
taxCategories: FieldOption[];
shippingRestrictions: FieldOption[];
}
export interface LineOption {
label: string;
value: string;
}
export interface ProductImage {
iid: number | string; // number for existing, string like "new-0" for added
order: number;
hidden: boolean;
sizes: Record<string, { width: number; height: number; url: string }>;
imageUrl?: string; // for newly added images (the uploaded URL)
isNew?: boolean;
}
export interface ProductFormValues {
name: string;
company: string;
line: string;
subline: string;
supplier: string;
upc: string;
item_number: string;
supplier_no: string;
notions_no: string;
msrp: string;
cost_each: string;
qty_per_unit: string;
case_qty: string;
tax_cat: string;
artist: string;
weight: string;
length: string;
width: string;
height: string;
ship_restrictions: string;
coo: string;
hts_code: string;
size_cat: string;
description: string;
priv_notes: string;
}

View File

@@ -5,6 +5,7 @@ import { Providers } from "./components/Providers"
import type { RsiProps } from "./types" import type { RsiProps } from "./types"
import { ModalWrapper } from "./components/ModalWrapper" import { ModalWrapper } from "./components/ModalWrapper"
import { translations } from "./translationsRSIProps" import { translations } from "./translationsRSIProps"
import { ImportSessionProvider } from "@/contexts/ImportSessionContext"
// Simple empty theme placeholder // Simple empty theme placeholder
export const defaultTheme = {} export const defaultTheme = {}
@@ -29,10 +30,12 @@ export const ReactSpreadsheetImport = <T extends string>(propsWithoutDefaults: R
props.translations !== translations ? merge(translations, props.translations) : translations props.translations !== translations ? merge(translations, props.translations) : translations
return ( return (
<Providers rsiValues={{ ...props, translations: mergedTranslations }}> <ImportSessionProvider>
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}> <Providers rsiValues={{ ...props, translations: mergedTranslations }}>
<Steps /> <ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
</ModalWrapper> <Steps />
</Providers> </ModalWrapper>
</Providers>
</ImportSessionProvider>
) )
} }

View File

@@ -0,0 +1,249 @@
/**
* CloseConfirmationDialog Component
*
* Single dialog shown when user attempts to close the import modal.
* Named sessions: Save & Exit or Cancel.
* Unnamed sessions: Keep (autosave), Save with Name (inline input), Discard, or Cancel.
*/
import { useState, useContext, useEffect } from 'react';
import { Loader2, Save, Trash2, ArrowLeft } from 'lucide-react';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogPortal,
AlertDialogOverlay,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useImportSession } from '@/contexts/ImportSessionContext';
import { AuthContext } from '@/contexts/AuthContext';
import { deleteAutosaveSession, deleteSession as deleteSessionApi } from '@/services/importSessionApi';
interface CloseConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirmClose: () => void;
}
export function CloseConfirmationDialog({
open,
onOpenChange,
onConfirmClose,
}: CloseConfirmationDialogProps) {
const {
sessionName,
sessionId,
isDirty,
forceSave,
saveAsNamed,
clearSession,
getSuggestedSessionName,
} = useImportSession();
const { user } = useContext(AuthContext);
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showNameInput, setShowNameInput] = useState(false);
// Reset state when dialog opens
useEffect(() => {
if (open) {
const suggested = getSuggestedSessionName();
setName(suggested || '');
setError(null);
setShowNameInput(false);
}
}, [open, getSuggestedSessionName]);
// Only use local saving state for disabling buttons — don't block the user
// from closing just because a background autosave is in progress.
const isProcessing = saving;
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
const suggested = getSuggestedSessionName();
setName(suggested || '');
setError(null);
setShowNameInput(false);
}
onOpenChange(newOpen);
};
// Save & Exit (for named sessions, or "Keep" for unnamed)
const handleSaveAndExit = async () => {
setSaving(true);
try {
await forceSave();
onConfirmClose();
} catch (err) {
console.error('Failed to save:', err);
onConfirmClose();
} finally {
setSaving(false);
}
};
// Save with the entered name, then exit
const handleSaveWithName = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
setError('Enter a name');
return;
}
setSaving(true);
try {
await saveAsNamed(trimmedName);
onConfirmClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
};
// Discard session and exit
const handleDiscardAndExit = async () => {
setSaving(true);
try {
if (sessionId) {
await deleteSessionApi(sessionId);
} else if (user?.id) {
await deleteAutosaveSession(user.id);
}
clearSession();
onConfirmClose();
} catch (err) {
console.error('Failed to discard session:', err);
clearSession();
onConfirmClose();
} finally {
setSaving(false);
}
};
// --- Named session: simple save & exit ---
if (sessionName) {
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className="z-[1500]">
<AlertDialogHeader>
<AlertDialogTitle>Exit Import</AlertDialogTitle>
<AlertDialogDescription>
{isDirty
? `Your session "${sessionName}" will be saved. You can restore it later from the upload step.`
: `Your session "${sessionName}" is saved. You can restore it later from the upload step.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isProcessing}>Cancel</AlertDialogCancel>
<Button onClick={handleSaveAndExit} disabled={isProcessing}>
{isProcessing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</>
) : (
'Save & Exit'
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
);
}
// --- Unnamed session: all options in one view ---
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className={`z-[1500] ${showNameInput ? 'max-w-[400px]' : 'max-w-[700px]'}`}>
<AlertDialogHeader>
<AlertDialogTitle>{showNameInput ? 'Save As...' : 'Exit Product Import'}</AlertDialogTitle>
<AlertDialogDescription>
{showNameInput ? 'Enter a name for your session to save it.' : 'Your progress has been auto-saved. You can keep it, save it with a name, or discard it.'}
</AlertDialogDescription>
</AlertDialogHeader>
{/* Inline name input - shown only when user clicks "Save with Name" */}
{showNameInput && (
<>
<div className="flex items-center justify-end gap-2 px-0 py-1">
<Input
placeholder="Session name"
value={name}
onChange={(e) => { setName(e.target.value); setError(null); }}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isProcessing && name.trim()) {
handleSaveWithName();
}
}}
disabled={isProcessing}
className="w-[200px]"
autoFocus
/>
<Button
onClick={handleSaveWithName}
disabled={isProcessing || !name.trim()}
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<><Save className="h-4 w-4 mr-1" />Save & Exit</>
)}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</>
)}
{!showNameInput && (
<AlertDialogFooter className="flex-col sm:flex-row gap-1">
<>
<AlertDialogCancel disabled={isProcessing} className="mt-0">
<ArrowLeft className="h-4 w-4" /> Continue Editing
</AlertDialogCancel>
<Button
variant="outline"
onClick={handleDiscardAndExit}
disabled={isProcessing}
className="text-destructive hover:text-destructive/90"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
Discard
</Button>
<Button
onClick={() => setShowNameInput(true)}
disabled={isProcessing || showNameInput}
variant="outline"
>
<Save className="h-4 w-4" />
Save As...
</Button>
<Button onClick={handleSaveAndExit} disabled={isProcessing}>
{isProcessing ? (
<><Loader2 className="h-4 w-4 animate-spin" />Saving...</>
) : (
'Keep & Exit'
)}
</Button>
</>
</AlertDialogFooter> )}
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
);
}

View File

@@ -1,26 +1,12 @@
import type React from "react" import type React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { import {
Dialog, Dialog,
DialogContent,
DialogOverlay,
DialogPortal,
DialogClose,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { import { X } from "lucide-react"
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogPortal,
AlertDialogOverlay,
} from "@/components/ui/alert-dialog"
import { useRsi } from "../hooks/useRsi" import { useRsi } from "../hooks/useRsi"
import { useState, useCallback } from "react" import { useState, useCallback, useRef } from "react"
import { CloseConfirmationDialog } from "./CloseConfirmationDialog"
type Props = { type Props = {
children: React.ReactNode children: React.ReactNode
@@ -29,76 +15,85 @@ type Props = {
} }
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => { export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
const { rtl, translations } = useRsi() const { rtl } = useRsi()
const [showCloseAlert, setShowCloseAlert] = useState(false) const [showCloseAlert, setShowCloseAlert] = useState(false)
// Guard: when we're programmatically closing, don't re-show the alert
// Create a handler that resets scroll positions before closing const closingRef = useRef(false)
const handleClose = useCallback(() => {
// Reset all scroll positions in the dialog // Called after user confirms close in the dialog
const handleConfirmClose = useCallback(() => {
// Dismiss the confirmation dialog
setShowCloseAlert(false)
// Mark that we're intentionally closing so onOpenChange doesn't re-trigger the alert
closingRef.current = true
// Reset scroll positions
const scrollContainers = document.querySelectorAll('.overflow-auto, .overflow-scroll'); const scrollContainers = document.querySelectorAll('.overflow-auto, .overflow-scroll');
scrollContainers.forEach(container => { scrollContainers.forEach(container => {
if (container instanceof HTMLElement) { if (container instanceof HTMLElement) {
// Reset scroll position to top-left
container.scrollTop = 0; container.scrollTop = 0;
container.scrollLeft = 0; container.scrollLeft = 0;
} }
}); });
// Call the original onClose handler // Close the main dialog
onClose(); onClose();
// Reset the guard after a tick (after Radix fires onOpenChange)
requestAnimationFrame(() => {
closingRef.current = false
})
}, [onClose]); }, [onClose]);
// Radix fires this when something tries to change the dialog's open state
// (e.g. focus loss, internal close). We intercept to show our confirmation instead.
const handleDialogOpenChange = useCallback((open: boolean) => {
if (!open && !closingRef.current) {
// Something is trying to close the dialog — show confirmation
setShowCloseAlert(true)
}
}, [])
return ( return (
<> <>
<Dialog open={isOpen} onOpenChange={() => setShowCloseAlert(true)} modal> {/*
<DialogPortal> NOTE: We use DialogPrimitive.Portal/Overlay/Content directly instead of the
<DialogOverlay className="bg-background/80 backdrop-blur-sm" /> shadcn DialogContent component. The shadcn DialogContent internally renders its
<DialogContent own Portal + Overlay, which would create duplicate portals/overlays and break
pointer-events for nested Popovers (e.g., select dropdowns).
*/}
<Dialog open={isOpen} onOpenChange={handleDialogOpenChange} modal>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-primary/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
onEscapeKeyDown={(e) => { onEscapeKeyDown={(e) => {
e.preventDefault() e.preventDefault()
setShowCloseAlert(true) setShowCloseAlert(true)
}} }}
onPointerDownOutside={(e) => e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()}
className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] h-[calc(100%-2rem)] max-w-[100vw] max-h-[100vh] flex flex-col overflow-hidden rounded-lg border bg-background p-0 shadow-lg sm:w-[calc(100%-3rem)] sm:h-[calc(100%-3rem)] md:w-[calc(100%-4rem)] md:h-[calc(100%-4rem)]" className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] h-[calc(100%-2rem)] max-w-[100vw] max-h-[100vh] flex flex-col overflow-hidden rounded-lg border bg-background p-0 shadow-lg sm:w-[calc(100%-3rem)] sm:h-[calc(100%-3rem)] md:w-[calc(100%-4rem)] md:h-[calc(100%-4rem)]"
> >
<AlertDialog> <button
<AlertDialogTrigger asChild> type="button"
<DialogClose className="absolute right-4 top-4" onClick={(e) => { className="absolute right-4 top-4 z-10 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
e.preventDefault() onClick={() => setShowCloseAlert(true)}
setShowCloseAlert(true) >
}} /> <X className="h-4 w-4" />
</AlertDialogTrigger> <span className="sr-only">Close</span>
</AlertDialog> </button>
<div dir={rtl ? "rtl" : "ltr"} className="flex-1 overflow-auto"> <div dir={rtl ? "rtl" : "ltr"} className="flex-1 overflow-auto">
{children} {children}
</div> </div>
</DialogContent> </DialogPrimitive.Content>
</DialogPortal> </DialogPrimitive.Portal>
</Dialog> </Dialog>
<AlertDialog open={showCloseAlert} onOpenChange={setShowCloseAlert}> <CloseConfirmationDialog
<AlertDialogPortal> open={showCloseAlert}
<AlertDialogOverlay className="z-[1400]" /> onOpenChange={setShowCloseAlert}
<AlertDialogContent className="z-[1500]"> onConfirmClose={handleConfirmClose}
<AlertDialogHeader> />
<AlertDialogTitle>
{translations.alerts.confirmClose.headerTitle}
</AlertDialogTitle>
<AlertDialogDescription>
{translations.alerts.confirmClose.bodyText}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowCloseAlert(false)}>
{translations.alerts.confirmClose.cancelButtonTitle}
</AlertDialogCancel>
<AlertDialogAction onClick={handleClose}>
{translations.alerts.confirmClose.exitButtonTitle}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
</> </>
) )
} }

View File

@@ -0,0 +1,178 @@
/**
* SaveSessionDialog Component
*
* Dialog for saving the current import session with a name.
*/
import { useState, useEffect } from 'react';
import { Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useImportSession } from '@/contexts/ImportSessionContext';
import { toast } from 'sonner';
interface SaveSessionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Optional suggested name to pre-populate the input */
suggestedName?: string | null;
/** Optional callback after successful save */
onSaved?: () => void;
}
export function SaveSessionDialog({ open, onOpenChange, suggestedName, onSaved }: SaveSessionDialogProps) {
const { saveAsNamed, sessionName, getSuggestedSessionName } = useImportSession();
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
// Initialize name when dialog opens
useEffect(() => {
if (open && !initialized) {
// Priority: existing session name > provided suggested name > auto-generated from data
const initialName = sessionName || suggestedName || getSuggestedSessionName() || '';
setName(initialName);
setInitialized(true);
}
// Reset initialized flag when dialog closes
if (!open) {
setInitialized(false);
}
}, [open, initialized, sessionName, suggestedName, getSuggestedSessionName]);
const handleSave = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
setError('Please enter a name for the session');
return;
}
try {
setSaving(true);
setError(null);
await saveAsNamed(trimmedName);
toast.success('Session saved successfully');
onOpenChange(false);
onSaved?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save session');
} finally {
setSaving(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !saving && name.trim()) {
handleSave();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Save className="h-5 w-5" />
Save Session
</DialogTitle>
<DialogDescription>
Save your current progress with a name so you can restore it later.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="session-name">Session Name</Label>
<Input
id="session-name"
placeholder="e.g., Spring 2025 Products"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
disabled={saving}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* SaveSessionButton Component
*
* A button that opens the save session dialog.
* Shows current save status.
*/
interface SaveSessionButtonProps {
className?: string;
}
export function SaveSessionButton({ className }: SaveSessionButtonProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const { isSaving, lastSaved, sessionName } = useImportSession();
return (
<>
<div className={`flex items-center gap-2 ${className || ''}`}>
{lastSaved && (
<span className="text-xs text-muted-foreground">
{sessionName ? `Saved as "${sessionName}"` : 'Auto-saved'}{' '}
{new Date(lastSaved).toLocaleTimeString()}
</span>
)}
{isSaving && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Saving...
</span>
)}
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(true)}
disabled={isSaving}
>
<Save className="h-4 w-4 mr-1" />
{sessionName ? 'Save As...' : 'Save Session'}
</Button>
</div>
<SaveSessionDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
}

View File

@@ -0,0 +1,240 @@
/**
* SavedSessionsList Component
*
* Displays a list of saved import sessions on the upload step.
* Shows named sessions and the previous unnamed session (if exists).
*/
import { useState, useEffect, useContext } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { Loader2, Trash2, RotateCcw, Clock, FileSpreadsheet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { AuthContext } from '@/contexts/AuthContext';
import { importSessionApi } from '@/services/importSessionApi';
import type { ImportSessionListItem, ImportSession } from '@/types/importSession';
interface SavedSessionsListProps {
onRestore: (session: ImportSession) => void;
}
export function SavedSessionsList({ onRestore }: SavedSessionsListProps) {
const { user } = useContext(AuthContext);
const [sessions, setSessions] = useState<ImportSessionListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [restoringId, setRestoringId] = useState<number | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
// Fetch sessions on mount
useEffect(() => {
if (!user?.id) return;
async function fetchSessions() {
try {
setLoading(true);
setError(null);
const result = await importSessionApi.list(user!.id);
setSessions(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load sessions');
} finally {
setLoading(false);
}
}
fetchSessions();
}, [user?.id]);
// Handle restore
const handleRestore = async (sessionItem: ImportSessionListItem) => {
try {
setRestoringId(sessionItem.id);
// Fetch full session data
const fullSession = await importSessionApi.get(sessionItem.id);
onRestore(fullSession);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to restore session');
} finally {
setRestoringId(null);
}
};
// Handle delete
const handleDelete = async (id: number) => {
try {
setDeletingId(id);
await importSessionApi.delete(id);
setSessions(prev => prev.filter(s => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete session');
} finally {
setDeletingId(null);
setDeleteConfirmId(null);
}
};
// Separate unnamed (previous) session from named sessions
const unnamedSession = sessions.find(s => s.name === null);
const namedSessions = sessions.filter(s => s.name !== null);
// Don't render anything if no sessions
if (!loading && sessions.length === 0) {
return null;
}
return (
<>
<Card className="mt-8">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" />
Saved Sessions
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="text-sm text-destructive py-4">{error}</div>
) : (
<div className="space-y-2">
{/* Previous session (unnamed) */}
{unnamedSession && (
<SessionRow
session={unnamedSession}
isPrevious
isRestoring={restoringId === unnamedSession.id}
isDeleting={deletingId === unnamedSession.id}
onRestore={() => handleRestore(unnamedSession)}
onDelete={() => setDeleteConfirmId(unnamedSession.id)}
/>
)}
{/* Named sessions */}
{namedSessions.map(session => (
<SessionRow
key={session.id}
session={session}
isRestoring={restoringId === session.id}
isDeleting={deletingId === session.id}
onRestore={() => handleRestore(session)}
onDelete={() => setDeleteConfirmId(session.id)}
/>
))}
</div>
)}
</CardContent>
</Card>
{/* Delete confirmation dialog */}
<AlertDialog open={deleteConfirmId !== null} onOpenChange={() => setDeleteConfirmId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Session?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this saved session. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
interface SessionRowProps {
session: ImportSessionListItem;
isPrevious?: boolean;
isRestoring: boolean;
isDeleting: boolean;
onRestore: () => void;
onDelete: () => void;
}
function SessionRow({
session,
isPrevious,
isRestoring,
isDeleting,
onRestore,
onDelete,
}: SessionRowProps) {
const timeAgo = formatDistanceToNow(new Date(session.updated_at), { addSuffix: true });
return (
<div className="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{isPrevious ? (
<span className="text-amber-600">Previous session</span>
) : (
session.name
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-0.5">
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{timeAgo}
</span>
<span>{session.row_count} product{session.row_count !== 1 ? 's' : ''}</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
size="sm"
variant="outline"
onClick={onRestore}
disabled={isRestoring || isDeleting}
>
{isRestoring ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<RotateCcw className="h-4 w-4 mr-1" />
Restore
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={onDelete}
disabled={isRestoring || isDeleting}
className="text-destructive hover:text-destructive/90"
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</>
)}
</Button>
</div>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
PointerSensor, PointerSensor,
useSensor, useSensor,
@@ -24,8 +24,22 @@ import { useBulkImageUpload } from "./hooks/useBulkImageUpload";
import { useUrlImageUpload } from "./hooks/useUrlImageUpload"; import { useUrlImageUpload } from "./hooks/useUrlImageUpload";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { AuthContext } from "@/contexts/AuthContext"; import { AuthContext } from "@/contexts/AuthContext";
import { useImportAutosave } from "@/hooks/useImportAutosave";
import { useImportSession } from "@/contexts/ImportSessionContext";
import { SaveSessionButton } from "../../components/SaveSessionDialog";
import type { SubmitOptions } from "../../types"; import type { SubmitOptions } from "../../types";
import type { ImportSessionData } from "@/types/importSession";
interface Props { interface Props {
data: Product[]; data: Product[];
@@ -48,7 +62,12 @@ export const ImageUploadStep = ({
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod"); const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false); const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false); const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false);
const [showNewProduct, setShowNewProduct] = useState<boolean>(false);
const [showConfirmDialog, setShowConfirmDialog] = useState<boolean>(false);
// Import session context for cleanup on submit and global selections
const { deleteSession: deleteImportSession, getGlobalSelections } = useImportSession();
// Use our hook for product images initialization // Use our hook for product images initialization
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data); const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
@@ -89,7 +108,32 @@ export const ImageUploadStep = ({
data, data,
handleImageUpload handleImageUpload
}); });
// Autosave hook for session persistence
const { markDirty } = useImportAutosave({
enabled: true,
step: 'imageUpload',
getSessionData: useCallback((): ImportSessionData => {
return {
current_step: 'imageUpload',
data: data as any[], // Product data
product_images: productImages,
global_selections: getGlobalSelections(),
};
}, [data, productImages, getGlobalSelections]),
});
// Mark dirty when images change (use ref to avoid depending on markDirty identity)
const markDirtyRef = useRef(markDirty);
markDirtyRef.current = markDirty;
const prevProductImagesRef = useRef(productImages);
useEffect(() => {
if (prevProductImagesRef.current !== productImages) {
prevProductImagesRef.current = productImages;
markDirtyRef.current();
}
}, [productImages]);
// Set up sensors for drag and drop with enhanced configuration // Set up sensors for drag and drop with enhanced configuration
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -171,33 +215,58 @@ export const ImageUploadStep = ({
return { return {
...product, ...product,
// Store as comma-separated string to ensure compatibility // Store as comma-separated string to ensure compatibility
product_images: images.join(',') product_images: images.join(','),
// Add show_new_product flag if enabled
...(showNewProduct && { show_new_product: true })
}; };
}); });
const submitOptions: SubmitOptions = { const submitOptions: SubmitOptions = {
targetEnvironment, targetEnvironment,
useTestDataSource, useTestDataSource,
skipApiSubmission, skipApiSubmission,
showNewProduct,
}; };
await onSubmit(updatedData, file, submitOptions); await onSubmit(updatedData, file, submitOptions);
// Delete the import session on successful submit
try {
await deleteImportSession();
} catch (err) {
// Non-critical - log but don't fail the submission
console.warn('Failed to delete import session:', err);
}
} catch (error) { } catch (error) {
console.error('Submit error:', error); console.error('Submit error:', error);
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`); toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission]); }, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission, showNewProduct, deleteImportSession]);
return ( return (
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden"> <div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden relative">
{/* Full-screen loading overlay during submit */}
{isSubmitting && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-4">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="text-lg font-medium">Submitting products...</div>
<div className="text-sm text-muted-foreground">Please wait while your import is being processed</div>
</div>
)}
{/* Header - fixed at top */} {/* Header - fixed at top */}
<div className="px-8 py-6 bg-background shrink-0"> <div className="px-8 py-6 bg-background shrink-0">
<h2 className="text-2xl font-semibold">Add Product Images</h2> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground mt-1"> <div>
Drag images to reorder them or move them between products. <h2 className="text-2xl font-semibold">Add Product Images</h2>
</p> <p className="text-sm text-muted-foreground mt-1">
Drag images to reorder them or move them between products.
</p>
</div>
<SaveSessionButton />
</div>
</div> </div>
{/* Content area - only this part scrolls */} {/* Content area - only this part scrolls */}
@@ -297,6 +366,24 @@ export const ImageUploadStep = ({
</Button> </Button>
)} )}
<div className="flex flex-1 flex-wrap items-center justify-end gap-6"> <div className="flex flex-1 flex-wrap items-center justify-end gap-6">
<div className="flex items-center gap-1">
<Switch
id="product-import-show-new-product"
checked={showNewProduct}
onCheckedChange={(checked) => {
if (checked) {
setShowConfirmDialog(true);
} else {
setShowNewProduct(false);
}
}}
/>
<div>
<Label htmlFor="product-import-show-new-product" className="text-sm font-medium">
Show these products immediately
</Label>
</div>
</div>
{hasDebugPermission && ( {hasDebugPermission && (
<div className="flex gap-4 text-sm"> <div className="flex gap-4 text-sm">
{!skipApiSubmission && ( {!skipApiSubmission && (
@@ -351,6 +438,30 @@ export const ImageUploadStep = ({
</Button> </Button>
</div> </div>
</div> </div>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Show products immediately?</AlertDialogTitle>
<AlertDialogDescription>
This will create all of these products with the "hide" option NOT set, so they will be immediately visible on the site when received or put on pre-order. Do NOT use this option for products we're not allowed to show yet.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowConfirmDialog(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowNewProduct(true);
setShowConfirmDialog(false);
}}
>
Yes, show immediately
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
}; };

View File

@@ -25,7 +25,7 @@ const getFullImageUrl = (url: string): string => {
} }
// Otherwise, it's a relative URL, prepend the domain // Otherwise, it's a relative URL, prepend the domain
const baseUrl = 'https://acot.site'; const baseUrl = 'https://tools.acherryontop.com';
// Make sure url starts with / for path // Make sure url starts with / for path
const path = url.startsWith('/') ? url : `/${url}`; const path = url.startsWith('/') ? url : `/${url}`;
return `${baseUrl}${path}`; return `${baseUrl}${path}`;

View File

@@ -74,7 +74,7 @@ export const useProductImagesInit = (data: Product[]) => {
} }
// Otherwise, it's a relative URL, prepend the domain // Otherwise, it's a relative URL, prepend the domain
const baseUrl = 'https://acot.site'; const baseUrl = 'https://tools.acherryontop.com';
// Make sure url starts with / for path // Make sure url starts with / for path
const path = url.startsWith('/') ? url : `/${url}`; const path = url.startsWith('/') ? url : `/${url}`;
return `${baseUrl}${path}`; return `${baseUrl}${path}`;

View File

@@ -1,36 +1,37 @@
import { StepState, StepType, UploadFlow } from "./UploadFlow" import { StepState, StepType, UploadFlow } from "./UploadFlow"
import { useRsi } from "../hooks/useRsi" import { useRsi } from "../hooks/useRsi"
import { useRef, useState, useEffect } from "react" import { useRef, useState, useEffect, useContext } from "react"
import { steps, stepTypeToStepIndex, stepIndexToStepType } from "../utils/steps" import { steps, stepTypeToStepIndex, stepIndexToStepType } from "../utils/steps"
import { CgCheck } from "react-icons/cg" import { CgCheck } from "react-icons/cg"
import { ImportSessionContext } from "@/contexts/ImportSessionContext"
const CheckIcon = ({ color }: { color: string }) => <CgCheck size="24" className={color} /> const CheckIcon = ({ color }: { color: string }) => <CgCheck size="24" className={color} />
export const Steps = () => { export const Steps = () => {
const { initialStepState, translations, isNavigationEnabled, isOpen } = useRsi() const { initialStepState, translations, isNavigationEnabled } = useRsi()
const { clearSession } = useContext(ImportSessionContext)
const initialStep = stepTypeToStepIndex(initialStepState?.type) const initialStep = stepTypeToStepIndex(initialStepState?.type)
const [activeStep, setActiveStep] = useState(initialStep) const [activeStep, setActiveStep] = useState(initialStep)
const [state, setState] = useState<StepState>(initialStepState || { type: StepType.upload }) const [state, setState] = useState<StepState>(initialStepState || { type: StepType.upload })
const history = useRef<StepState[]>([]) const history = useRef<StepState[]>([])
const prevIsOpen = useRef(isOpen)
// Reset state when dialog is reopened // Clear previous session on mount so each open starts fresh.
// Steps unmounts when the dialog closes (Radix portal), so every open = fresh mount.
useEffect(() => { useEffect(() => {
// Check if dialog was closed and is now open again clearSession()
if (isOpen && !prevIsOpen.current) { // eslint-disable-next-line react-hooks/exhaustive-deps
// Reset to initial state }, [])
setActiveStep(initialStep)
setState(initialStepState || { type: StepType.upload })
history.current = []
}
// Update previous isOpen value
prevIsOpen.current = isOpen
}, [isOpen, initialStep, initialStepState])
const onClickStep = (stepIndex: number) => { const onClickStep = (stepIndex: number) => {
const type = stepIndexToStepType(stepIndex) const type = stepIndexToStepType(stepIndex)
const historyIdx = history.current.findIndex((v) => v.type === type) let historyIdx = history.current.findIndex((v) => v.type === type)
// Special case: step index 0 could be either upload or selectSheet
// If we didn't find upload, also check for selectSheet
if (historyIdx === -1 && stepIndex === 0) {
historyIdx = history.current.findIndex((v) => v.type === StepType.selectSheet)
}
if (historyIdx === -1) return if (historyIdx === -1) return
const nextHistory = history.current.slice(0, historyIdx + 1) const nextHistory = history.current.slice(0, historyIdx + 1)
history.current = nextHistory history.current = nextHistory
@@ -39,19 +40,29 @@ export const Steps = () => {
} }
const onBack = () => { const onBack = () => {
onClickStep(Math.max(activeStep - 1, 0)) // For back navigation, we want to go to the previous entry in history
// rather than relying on step index, since selectSheet shares index with upload
if (history.current.length > 0) {
const previousState = history.current[history.current.length - 1]
history.current = history.current.slice(0, -1)
setState(previousState)
setActiveStep(stepTypeToStepIndex(previousState.type))
}
} }
const onNext = (v: StepState) => { const onNext = (v: StepState) => {
history.current.push(state) history.current.push(state)
setState(v) setState(v)
if (v.type === StepType.validateData && 'isFromScratch' in v && v.isFromScratch) { if (v.type === StepType.validateData && 'isFromScratch' in v && v.isFromScratch) {
// If starting from scratch, jump directly to the validation step // If starting from scratch, jump directly to the validation step
const validationStepIndex = steps.indexOf('validationStep') const validationStepIndex = steps.indexOf('validationStep')
setActiveStep(validationStepIndex) setActiveStep(validationStepIndex)
} else if (v.type !== StepType.selectSheet) { } else if (v.type !== StepType.selectSheet) {
setActiveStep(activeStep + 1) // Use the step type to determine the correct index directly,
// rather than incrementing, to avoid stale closure issues
const targetIndex = stepTypeToStepIndex(v.type)
setActiveStep(targetIndex)
} }
} }

View File

@@ -16,6 +16,8 @@ import { Progress } from "@/components/ui/progress"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations" import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { useValidationStore } from "./ValidationStep/store/validationStore" import { useValidationStore } from "./ValidationStep/store/validationStore"
import { useImportSession } from "@/contexts/ImportSessionContext"
import type { ImportSession } from "@/types/importSession"
export enum StepType { export enum StepType {
upload = "upload", upload = "upload",
@@ -123,10 +125,46 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
// Keep track of global selections across steps // Keep track of global selections across steps
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>( const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns
? state.globalSelections ? state.globalSelections
: undefined : undefined
) )
// Import session context for session restoration
const { loadSession, setGlobalSelections: setSessionGlobalSelections } = useImportSession()
// Sync global selections to session context for autosave
useEffect(() => {
setSessionGlobalSelections(persistedGlobalSelections)
}, [persistedGlobalSelections, setSessionGlobalSelections])
// Handle restoring a saved session
const handleRestoreSession = useCallback((session: ImportSession) => {
// Load the session into context
loadSession(session)
// Update global selections if they exist
if (session.global_selections) {
setPersistedGlobalSelections(session.global_selections)
}
// Navigate to the appropriate step with session data
if (session.current_step === 'imageUpload') {
onNext({
type: StepType.imageUpload,
data: session.data,
file: new File([], "restored-session.xlsx"),
globalSelections: session.global_selections || undefined,
})
} else {
// Default to validation step
onNext({
type: StepType.validateDataNew,
data: session.data,
globalSelections: session.global_selections || undefined,
isFromScratch: true, // Treat restored sessions like "from scratch" for back navigation
})
}
}, [loadSession, onNext])
switch (state.type) { switch (state.type) {
case StepType.upload: case StepType.upload:
@@ -164,6 +202,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
}); });
} }
}} }}
onRestoreSession={handleRestoreSession}
/> />
) )
case StepType.selectSheet: case StepType.selectSheet:

View File

@@ -1,19 +1,42 @@
import type XLSX from "xlsx" import type XLSX from "xlsx"
import { useCallback, useState } from "react" import { useCallback, useState, useContext } from "react"
import { useRsi } from "../../hooks/useRsi" import { useRsi } from "../../hooks/useRsi"
import { DropZone } from "./components/DropZone" import { DropZone } from "./components/DropZone"
import { StepType } from "../UploadFlow" import { StepType } from "../UploadFlow"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { AuthContext } from "@/contexts/AuthContext"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Bug } from "lucide-react"
import { v4 as uuidv4 } from "uuid"
import { SavedSessionsList } from "../../components/SavedSessionsList"
import type { ImportSession } from "@/types/importSession"
type UploadProps = { type UploadProps = {
onContinue: (data: XLSX.WorkBook, file: File) => Promise<void> onContinue: (data: XLSX.WorkBook, file: File) => Promise<void>
setInitialState?: (state: { type: StepType; data: any[]; isFromScratch?: boolean }) => void setInitialState?: (state: { type: StepType; data: any[]; isFromScratch?: boolean }) => void
onRestoreSession?: (session: ImportSession) => void
} }
export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => { export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: UploadProps) => {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { translations } = useRsi() const { translations } = useRsi()
const { user } = useContext(AuthContext)
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"))
// Debug import state
const [debugDialogOpen, setDebugDialogOpen] = useState(false)
const [debugJsonInput, setDebugJsonInput] = useState("")
const [debugError, setDebugError] = useState<string | null>(null)
const handleOnContinue = useCallback( const handleOnContinue = useCallback(
async (data: XLSX.WorkBook, file: File) => { async (data: XLSX.WorkBook, file: File) => {
@@ -29,11 +52,63 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
setInitialState({ type: StepType.validateData, data: [{}], isFromScratch: true }) setInitialState({ type: StepType.validateData, data: [{}], isFromScratch: true })
} }
}, [setInitialState]) }, [setInitialState])
const handleDebugImport = useCallback(() => {
setDebugError(null)
try {
const parsed = JSON.parse(debugJsonInput)
// Handle both array and object with products property
let products: any[] = Array.isArray(parsed) ? parsed : parsed.products
if (!Array.isArray(products) || products.length === 0) {
setDebugError("JSON must be an array of products or an object with a 'products' array")
return
}
// Add __index to each row if not present (required for validation step)
const dataWithIndex = products.map((row: any) => ({
...row,
__index: row.__index || uuidv4()
}))
if (setInitialState) {
setInitialState({
type: StepType.validateData,
data: dataWithIndex,
isFromScratch: true
})
}
setDebugDialogOpen(false)
setDebugJsonInput("")
} catch (e) {
setDebugError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`)
}
}, [debugJsonInput, setInitialState])
return ( return (
<div className="p-8"> <div className="p-8">
<div className="flex items-baseline justify-between">
<h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2> <h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2>
{hasDebugPermission && (
<>
<div className="flex justify-center">
<Button
onClick={() => setDebugDialogOpen(true)}
variant="outline"
className="min-w-[200px] text-amber-600 border-amber-600 hover:bg-amber-50"
disabled={!setInitialState}
>
<Bug className="mr-2 h-4 w-4" />
Import JSON
</Button>
</div>
</>
)}
</div>
<div className="max-w-xl mx-auto w-full space-y-8"> <div className="max-w-xl mx-auto w-full space-y-8">
<div className="rounded-lg p-6 flex flex-col items-center"> <div className="rounded-lg p-6 flex flex-col items-center">
<DropZone onContinue={handleOnContinue} isLoading={isLoading} /> <DropZone onContinue={handleOnContinue} isLoading={isLoading} />
@@ -45,8 +120,8 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
<Separator className="w-24" /> <Separator className="w-24" />
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center pb-8">
<Button <Button
onClick={handleStartFromScratch} onClick={handleStartFromScratch}
variant="outline" variant="outline"
className="min-w-[200px]" className="min-w-[200px]"
@@ -55,7 +130,56 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
Start from scratch Start from scratch
</Button> </Button>
</div> </div>
{/* Saved sessions list */}
{onRestoreSession && (
<SavedSessionsList onRestore={onRestoreSession} />
)}
</div> </div>
<Dialog open={debugDialogOpen} onOpenChange={setDebugDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-amber-600">
<Bug className="h-5 w-5" />
Debug: Import JSON Data
</DialogTitle>
<DialogDescription>
Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="debug-json">Product JSON</Label>
<Textarea
id="debug-json"
placeholder='[{"supplier": "...", "company": "...", "name": "...", "product_images": "url1,url2", ...}]'
value={debugJsonInput}
onChange={(e) => {
setDebugJsonInput(e.target.value)
setDebugError(null)
}}
className="min-h-[300px] font-mono text-sm"
/>
{debugError && (
<p className="text-sm text-destructive">{debugError}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDebugDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleDebugImport}
disabled={!debugJsonInput.trim()}
className="bg-amber-600 hover:bg-amber-700"
>
Import & Go to Validation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@@ -6,7 +6,7 @@
* Note: Initialization effects are in index.tsx so they run before this mounts. * Note: Initialization effects are in index.tsx so they run before this mounts.
*/ */
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { useValidationStore } from '../store/validationStore'; import { useValidationStore } from '../store/validationStore';
import { import {
useTotalErrorCount, useTotalErrorCount,
@@ -29,8 +29,11 @@ import { AiDebugDialog } from '../dialogs/AiDebugDialog';
import { SanityCheckDialog } from '../dialogs/SanityCheckDialog'; import { SanityCheckDialog } from '../dialogs/SanityCheckDialog';
import { TemplateForm } from '@/components/templates/TemplateForm'; import { TemplateForm } from '@/components/templates/TemplateForm';
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext'; import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
import { useImportAutosave } from '@/hooks/useImportAutosave';
import { useImportSession } from '@/contexts/ImportSessionContext';
import type { CleanRowData, RowData } from '../store/types'; import type { CleanRowData, RowData } from '../store/types';
import type { ProductForSanityCheck } from '../hooks/useSanityCheck'; import type { ProductForSanityCheck } from '../hooks/useSanityCheck';
import type { ImportSessionData, SerializedValidationState } from '@/types/importSession';
interface ValidationContainerProps { interface ValidationContainerProps {
onBack?: () => void; onBack?: () => void;
@@ -71,6 +74,50 @@ export const ValidationContainer = ({
// Handle UPC validation after copy-down operations on supplier/upc fields // Handle UPC validation after copy-down operations on supplier/upc fields
useCopyDownValidation(); useCopyDownValidation();
// Import session context for global selections
const { getGlobalSelections } = useImportSession();
// Import session autosave
const { markDirty } = useImportAutosave({
enabled: true,
step: 'validation',
getSessionData: useCallback((): ImportSessionData => {
const state = useValidationStore.getState();
// Serialize Maps to plain objects for JSON storage
const serializedValidationState: SerializedValidationState = {
errors: Object.fromEntries(
Array.from(state.errors.entries()).map(([k, v]) => [k, v])
),
upcStatus: Object.fromEntries(
Array.from(state.upcStatus.entries()).map(([k, v]) => [k, v])
),
generatedItemNumbers: Object.fromEntries(
Array.from(state.generatedItemNumbers.entries()).map(([k, v]) => [k, v])
),
};
return {
current_step: 'validation',
data: state.rows,
global_selections: getGlobalSelections(),
validation_state: serializedValidationState,
};
}, [getGlobalSelections]),
});
// Subscribe to store changes to trigger autosave
useEffect(() => {
// Subscribe to row changes to mark session as dirty
const unsubscribe = useValidationStore.subscribe(
(state) => state.rows,
() => {
markDirty();
}
);
return () => unsubscribe();
}, [markDirty]);
// Get initial products for AI suggestions (read once via ref to avoid re-fetching) // Get initial products for AI suggestions (read once via ref to avoid re-fetching)
const initialProductsRef = useRef<RowData[] | null>(null); const initialProductsRef = useRef<RowData[] | null>(null);
if (initialProductsRef.current === null) { if (initialProductsRef.current === null) {

View File

@@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { ArrowDown, Wand2, Loader2, Calculator, Scale } from 'lucide-react'; import { ArrowDown, Wand2, Loader2, Calculator, Scale, Pin, PinOff } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -27,6 +27,7 @@ import { useValidationStore } from '../store/validationStore';
import { import {
useFields, useFields,
useFilters, useFilters,
useRowCount,
} from '../store/selectors'; } from '../store/selectors';
// NOTE: We intentionally do NOT import useValidationActions or useProductLines here! // NOTE: We intentionally do NOT import useValidationActions or useProductLines here!
// Those hooks subscribe to global state (rows, errors, caches) which would cause // Those hooks subscribe to global state (rows, errors, caches) which would cause
@@ -1210,8 +1211,10 @@ interface VirtualRowProps {
columns: ColumnDef<RowData>[]; columns: ColumnDef<RowData>[];
fields: Field<string>[]; fields: Field<string>[];
totalRowCount: number; totalRowCount: number;
/** Whether table is scrolled horizontally - used for sticky column shadow */ /** Whether the name column sticky behavior is enabled */
isScrolledHorizontally: boolean; nameColumnSticky: boolean;
/** Direction for sticky name column: 'left', 'right', or null (not sticky) */
stickyDirection: 'left' | 'right' | null;
} }
const VirtualRow = memo(({ const VirtualRow = memo(({
@@ -1221,7 +1224,8 @@ const VirtualRow = memo(({
columns, columns,
fields, fields,
totalRowCount, totalRowCount,
isScrolledHorizontally, nameColumnSticky,
stickyDirection,
}: VirtualRowProps) => { }: VirtualRowProps) => {
// Subscribe to row data - this is THE subscription for all cell values in this row // Subscribe to row data - this is THE subscription for all cell values in this row
const rowData = useValidationStore( const rowData = useValidationStore(
@@ -1317,13 +1321,18 @@ const VirtualRow = memo(({
<div <div
data-row-index={rowIndex} data-row-index={rowIndex}
className={cn( className={cn(
'flex border-b absolute', 'flex absolute',
// Use box-shadow for bottom border - renders more consistently with transforms than border-b
'shadow-[inset_0_-1px_0_0_hsl(var(--border))]',
hasErrors && 'bg-destructive/5', hasErrors && 'bg-destructive/5',
isSelected && 'bg-primary/5' isSelected && 'bg-blue-100 dark:bg-blue-900/40'
)} )}
style={{ style={{
height: ROW_HEIGHT, height: ROW_HEIGHT,
transform: `translateY(${virtualStart}px)`, // Round to whole pixels to prevent sub-pixel rendering issues during scroll
transform: `translateY(${Math.round(virtualStart)}px)`,
// Promote to GPU layer for smoother rendering
willChange: 'transform',
// Elevate row when it has a visible AI suggestion so badge shows above next row // Elevate row when it has a visible AI suggestion so badge shows above next row
zIndex: hasVisibleAiSuggestion ? 10 : undefined, zIndex: hasVisibleAiSuggestion ? 10 : undefined,
}} }}
@@ -1331,7 +1340,7 @@ const VirtualRow = memo(({
> >
{/* Selection checkbox cell */} {/* Selection checkbox cell */}
<div <div
className="px-2 py-3 border-r flex items-start justify-center" className="px-2 py-3 flex items-start justify-center shadow-[inset_-1px_0_0_0_hsl(var(--border))]"
style={{ style={{
width: columns[0]?.size || 40, width: columns[0]?.size || 40,
minWidth: columns[0]?.size || 40, minWidth: columns[0]?.size || 40,
@@ -1346,7 +1355,7 @@ const VirtualRow = memo(({
{/* Template column */} {/* Template column */}
<div <div
className="px-2 py-2 border-r flex items-start overflow-hidden" className="px-2 py-2 flex items-start overflow-hidden shadow-[inset_-1px_0_0_0_hsl(var(--border))]"
style={{ style={{
width: TEMPLATE_COLUMN_WIDTH, width: TEMPLATE_COLUMN_WIDTH,
minWidth: TEMPLATE_COLUMN_WIDTH, minWidth: TEMPLATE_COLUMN_WIDTH,
@@ -1400,33 +1409,50 @@ const VirtualRow = memo(({
const isNameColumn = field.key === 'name'; const isNameColumn = field.key === 'name';
// Determine sticky behavior for name column
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
const stickyRight = shouldBeSticky && stickyDirection === 'right';
return ( return (
<div <div
key={field.key} key={field.key}
data-cell-field={field.key} data-cell-field={field.key}
className={cn( className={cn(
"px-2 py-2 border-r last:border-r-0 flex items-start", "px-2 py-2 flex items-start",
// Use box-shadow for right border - renders more consistently with transforms
// last:shadow-none removes the shadow from the last cell
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
// Name column needs overflow-visible for the floating AI suggestion badge // Name column needs overflow-visible for the floating AI suggestion badge
// Description handles AI suggestions inside its popover, so no overflow needed // Description handles AI suggestions inside its popover, so no overflow needed
isNameColumn ? "overflow-visible" : "overflow-hidden", isNameColumn ? "overflow-visible" : "overflow-hidden",
// Name column is sticky - needs SOLID (opaque) background that matches row state // Name column sticky behavior - only when enabled and scrolled appropriately
// Uses gradient trick to composite semi-transparent tint onto solid background shouldBeSticky && "lg:sticky lg:z-10",
// Shadow only shows when scrolled horizontally (column is actually overlaying content) // Add left border when sticky-right since content scrolls behind from the left
isNameColumn && "lg:sticky lg:z-10", stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border))]",
isNameColumn && isScrolledHorizontally && "lg:shadow-md", // Directional drop shadow on the outside edge where content scrolls behind (combined with border shadow)
isNameColumn && ( stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
hasErrors stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]" // Solid background when sticky to overlay content
: isSelected // Use explicit [background:] syntax for consistent specificity
? "lg:[background:linear-gradient(hsl(var(--primary)/0.05),hsl(var(--primary)/0.05)),hsl(var(--background))]" // Selection (blue) takes priority over errors (red)
: "lg:bg-background" shouldBeSticky && (
) isSelected
? "lg:[background:#dbeafe] lg:dark:[background:hsl(221_83%_25%/0.4)]"
: hasErrors
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
: "lg:[background:hsl(var(--background))]"
),
// Make child inputs/buttons transparent when sticky + selected so blue background shows through
shouldBeSticky && isSelected && "lg:[&_input]:!bg-transparent lg:[&_button]:!bg-transparent lg:[&_textarea]:!bg-transparent"
)} )}
style={{ style={{
width: columnWidth, width: columnWidth,
minWidth: columnWidth, minWidth: columnWidth,
flexShrink: 0, flexShrink: 0,
...(isNameColumn && { left: NAME_COLUMN_STICKY_LEFT }), // Position sticky left or right based on scroll direction
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
...(stickyRight && { right: 0 }),
}} }}
> >
<CellWrapper <CellWrapper
@@ -1462,27 +1488,79 @@ VirtualRow.displayName = 'VirtualRow';
/** /**
* Header checkbox component - isolated to prevent re-renders of the entire table * Header checkbox component - isolated to prevent re-renders of the entire table
* When filtering is active, only selects/deselects visible (filtered) rows
*/ */
const HeaderCheckbox = memo(() => { const HeaderCheckbox = memo(() => {
const rowCount = useValidationStore((state) => state.rows.length); const filters = useFilters();
const selectedCount = useValidationStore((state) => state.selectedRows.size); // Subscribe to row count to recalculate when rows are added/removed
const rowCount = useRowCount();
const allSelected = rowCount > 0 && selectedCount === rowCount; // Compute which rows are visible based on current filters
const someSelected = selectedCount > 0 && selectedCount < rowCount; const { visibleRowIds, visibleCount } = useMemo(() => {
const { rows, errors } = useValidationStore.getState();
const isFiltering = filters.searchText || filters.showErrorsOnly;
if (!isFiltering) {
// No filtering - all rows are visible
const ids = new Set(rows.map((row) => row.__index));
return { visibleRowIds: ids, visibleCount: rows.length };
}
// Apply filters to get visible row IDs
const ids = new Set<string>();
rows.forEach((row, index) => {
// Apply search filter
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matches = Object.values(row).some((value) =>
String(value ?? '').toLowerCase().includes(searchLower)
);
if (!matches) return;
}
// Apply errors-only filter
if (filters.showErrorsOnly) {
const rowErrors = errors.get(index);
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
}
ids.add(row.__index);
});
return { visibleRowIds: ids, visibleCount: ids.size };
}, [filters.searchText, filters.showErrorsOnly, rowCount]);
// Check selection state against visible rows only
const selectedRows = useValidationStore((state) => state.selectedRows);
const selectedVisibleCount = useMemo(() => {
let count = 0;
visibleRowIds.forEach((id) => {
if (selectedRows.has(id)) count++;
});
return count;
}, [visibleRowIds, selectedRows]);
const allVisibleSelected = visibleCount > 0 && selectedVisibleCount === visibleCount;
const someVisibleSelected = selectedVisibleCount > 0 && selectedVisibleCount < visibleCount;
const handleChange = useCallback((value: boolean | 'indeterminate') => { const handleChange = useCallback((value: boolean | 'indeterminate') => {
const { setSelectedRows, rows } = useValidationStore.getState(); const { setSelectedRows, selectedRows: currentSelected } = useValidationStore.getState();
if (value) { if (value) {
const allIds = new Set(rows.map((row) => row.__index)); // Add all visible rows to selection (keep existing selections of non-visible rows)
setSelectedRows(allIds); const newSelection = new Set(currentSelected);
visibleRowIds.forEach((id) => newSelection.add(id));
setSelectedRows(newSelection);
} else { } else {
setSelectedRows(new Set()); // Remove all visible rows from selection (keep selections of non-visible rows)
const newSelection = new Set(currentSelected);
visibleRowIds.forEach((id) => newSelection.delete(id));
setSelectedRows(newSelection);
} }
}, []); }, [visibleRowIds]);
return ( return (
<Checkbox <Checkbox
checked={allSelected || (someSelected && 'indeterminate')} checked={allVisibleSelected || (someVisibleSelected && 'indeterminate')}
onCheckedChange={handleChange} onCheckedChange={handleChange}
/> />
); );
@@ -1536,20 +1614,19 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
: 'Fill empty cells with MSRP ÷ 2'; : 'Fill empty cells with MSRP ÷ 2';
// Check if there are any cells that can be filled (called on hover) // Check if there are any cells that can be filled (called on hover)
// Now returns true if ANY row has a valid source value (allows overwriting existing values)
const checkFillableCells = useCallback(() => { const checkFillableCells = useCallback(() => {
const { rows } = useValidationStore.getState(); const { rows } = useValidationStore.getState();
return rows.some((row) => { return rows.some((row) => {
const currentValue = row[fieldKey];
const sourceValue = row[sourceField]; const sourceValue = row[sourceField];
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
if (isEmpty && hasSource) { if (hasSource) {
const sourceNum = parseFloat(String(sourceValue)); const sourceNum = parseFloat(String(sourceValue));
return !isNaN(sourceNum) && sourceNum > 0; return !isNaN(sourceNum) && sourceNum > 0;
} }
return false; return false;
}); });
}, [fieldKey, sourceField]); }, [sourceField]);
// Update fillable check on hover // Update fillable check on hover
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
@@ -1563,29 +1640,28 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
// Use setState() for efficient batch update with Immer // Use setState() for efficient batch update with Immer
useValidationStore.setState((draft) => { useValidationStore.setState((draft) => {
draft.rows.forEach((row, index) => { draft.rows.forEach((row, index) => {
const currentValue = row[fieldKey];
const sourceValue = row[sourceField]; const sourceValue = row[sourceField];
// Only fill if current field is empty and source has a value // Fill if source has a value (overwrite existing values based on source)
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
if (isEmpty && hasSource) { if (hasSource) {
const sourceNum = parseFloat(String(sourceValue)); const sourceNum = parseFloat(String(sourceValue));
if (!isNaN(sourceNum) && sourceNum > 0) { if (!isNaN(sourceNum) && sourceNum > 0) {
let msrp = sourceNum * multiplier; let msrp = sourceNum * multiplier;
if (multiplier === 2.0) { if (multiplier === 2.0) {
// For 2x: auto-adjust by ±1 cent to get to .99 if close // For 2x: auto-adjust by ±1 cent ONLY if result ends in .99
const cents = Math.round((msrp % 1) * 100); const cents = Math.round((msrp % 1) * 100);
if (cents === 0) { if (cents === 0 || cents === 98) {
// .00 → subtract 1 cent to get .99 const adjustment = cents === 0 ? -0.01 : 0.01;
msrp -= 0.01; const adjusted = (msrp + adjustment).toFixed(2);
} else if (cents === 98) { // Only apply if the adjusted value actually ends in .99
// .98 → add 1 cent to get .99 if (adjusted.endsWith('.99')) {
msrp += 0.01; msrp = parseFloat(adjusted);
}
} }
// Otherwise leave as-is // Otherwise leave as-is (exact 2x)
} else if (roundNine) { } else if (roundNine) {
// For >2x with checkbox: round to nearest .X9 // For >2x with checkbox: round to nearest .X9
msrp = roundToNine(msrp); msrp = roundToNine(msrp);
@@ -1616,13 +1692,12 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
useValidationStore.setState((draft) => { useValidationStore.setState((draft) => {
draft.rows.forEach((row, index) => { draft.rows.forEach((row, index) => {
const currentValue = row[fieldKey];
const sourceValue = row[sourceField]; const sourceValue = row[sourceField];
const isEmpty = currentValue === undefined || currentValue === null || currentValue === ''; // Fill if source has a value (overwrite existing values based on source)
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
if (isEmpty && hasSource) { if (hasSource) {
const sourceNum = parseFloat(String(sourceValue)); const sourceNum = parseFloat(String(sourceValue));
if (!isNaN(sourceNum) && sourceNum > 0) { if (!isNaN(sourceNum) && sourceNum > 0) {
draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2); draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2);
@@ -1928,6 +2003,165 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader'; UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader';
/**
* DefaultValueColumnHeader Component
*
* Renders a column header with a hover button that sets a default value for all rows.
* Used for Tax Category ("Not Specifically Set") and Shipping Restrictions ("None").
*
* PERFORMANCE: Uses local hover state and getState() for bulk updates.
*/
interface DefaultValueColumnHeaderProps {
fieldKey: 'tax_cat' | 'ship_restrictions';
label: string;
isRequired: boolean;
}
const DEFAULT_VALUE_CONFIG: Record<string, { value: string; displayName: string; buttonLabel: string }> = {
tax_cat: { value: '0', displayName: 'Not Specifically Set', buttonLabel: 'Set All Default' },
ship_restrictions: { value: '0', displayName: 'None', buttonLabel: 'Set All None' },
};
const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultValueColumnHeaderProps) => {
const [isHovered, setIsHovered] = useState(false);
const [hasEmptyCells, setHasEmptyCells] = useState(false);
const config = DEFAULT_VALUE_CONFIG[fieldKey];
// Check if there are any empty cells that can be filled
const checkEmptyCells = useCallback(() => {
const { rows } = useValidationStore.getState();
return rows.some((row) => {
const value = row[fieldKey];
return value === undefined || value === null || value === '';
});
}, [fieldKey]);
// Update empty check on hover
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
setHasEmptyCells(checkEmptyCells());
}, [checkEmptyCells]);
const handleSetDefault = useCallback(() => {
const updatedIndices: number[] = [];
useValidationStore.setState((draft) => {
draft.rows.forEach((row, index) => {
const value = row[fieldKey];
const isEmpty = value === undefined || value === null || value === '';
if (isEmpty) {
draft.rows[index][fieldKey] = config.value;
updatedIndices.push(index);
}
});
});
if (updatedIndices.length > 0) {
const { clearFieldError } = useValidationStore.getState();
updatedIndices.forEach((rowIndex) => {
clearFieldError(rowIndex, fieldKey);
});
toast.success(`Set ${updatedIndices.length} row${updatedIndices.length === 1 ? '' : 's'} to "${config.displayName}"`);
}
}, [fieldKey, config]);
return (
<div
className="flex items-center gap-1 truncate w-full group relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsHovered(false)}
>
<span className="truncate">{label}</span>
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
{isHovered && hasEmptyCells && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSetDefault();
}}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2',
'flex items-center gap-0.5',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity whitespace-nowrap'
)}
>
<Wand2 className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Fill empty cells with "{config.displayName}"</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
});
DefaultValueColumnHeader.displayName = 'DefaultValueColumnHeader';
/**
* NameColumnHeader Component
*
* Renders the Name column header with a sticky toggle button.
* Pin icon toggles whether the name column sticks to edges when scrolling.
*/
interface NameColumnHeaderProps {
label: string;
isRequired: boolean;
isSticky: boolean;
onToggleSticky: () => void;
}
const NameColumnHeader = memo(({ label, isRequired, isSticky, onToggleSticky }: NameColumnHeaderProps) => {
return (
<div className="flex items-center gap-1 truncate w-full group relative">
<span className="truncate">{label}</span>
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleSticky();
}}
className={cn(
'ml-auto flex items-center justify-center w-6 h-6 rounded',
'transition-colors',
isSticky
? 'text-primary bg-primary/10 hover:bg-primary/20'
: 'text-muted-foreground hover:bg-muted'
)}
>
{isSticky ? <Pin className="h-3.5 w-3.5" /> : <PinOff className="h-3.5 w-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{isSticky ? 'Unpin column' : 'Pin column'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
});
NameColumnHeader.displayName = 'NameColumnHeader';
/** /**
* Main table component * Main table component
* *
@@ -1958,18 +2192,46 @@ export const ValidationTable = () => {
return offset; return offset;
}, [fields]); }, [fields]);
// Track horizontal scroll for sticky column shadow // Name column sticky toggle - when enabled, column sticks to left/right edges based on scroll
const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false); const [nameColumnSticky, setNameColumnSticky] = useState(true);
// Sync header scroll with body scroll + track horizontal scroll state // Track scroll direction relative to name column: 'left' (stick to left) or 'right' (stick to right)
const [stickyDirection, setStickyDirection] = useState<'left' | 'right' | null>(null);
// Calculate name column width
const nameColumnWidth = useMemo(() => {
const nameField = fields.find(f => f.key === 'name');
return nameField?.width || 400;
}, [fields]);
// Sync header scroll with body scroll + track sticky direction
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
if (tableContainerRef.current && headerRef.current) { if (tableContainerRef.current && headerRef.current) {
const scrollLeft = tableContainerRef.current.scrollLeft; const scrollLeft = tableContainerRef.current.scrollLeft;
const viewportWidth = tableContainerRef.current.clientWidth;
headerRef.current.scrollLeft = scrollLeft; headerRef.current.scrollLeft = scrollLeft;
// Only show shadow when scrolled past the name column's natural position
setIsScrolledHorizontally(scrollLeft > nameColumnLeftOffset); // Calculate name column's position relative to viewport
const namePositionInViewport = nameColumnLeftOffset - scrollLeft;
const nameRightEdge = namePositionInViewport + nameColumnWidth;
// Determine sticky direction for name column
if (nameColumnSticky) {
if (scrollLeft > nameColumnLeftOffset) {
// Scrolled right past name column - stick to left
setStickyDirection('left');
} else if (nameRightEdge > viewportWidth) {
// Name column extends beyond viewport to the right - stick to right
setStickyDirection('right');
} else {
// Name column is fully visible - no sticky needed
setStickyDirection(null);
}
} else {
setStickyDirection(null);
}
} }
}, [nameColumnLeftOffset]); }, [nameColumnLeftOffset, nameColumnWidth, nameColumnSticky]);
// Compute filtered indices AND row IDs in a single pass // Compute filtered indices AND row IDs in a single pass
// This avoids calling getState() during render for each row // This avoids calling getState() during render for each row
@@ -2012,6 +2274,11 @@ export const ValidationTable = () => {
return { filteredIndices: indices, rowIdMap: idMap }; return { filteredIndices: indices, rowIdMap: idMap };
}, [rowCount, filters.searchText, filters.showErrorsOnly]); }, [rowCount, filters.searchText, filters.showErrorsOnly]);
// Toggle for sticky name column
const toggleNameColumnSticky = useCallback(() => {
setNameColumnSticky(prev => !prev);
}, []);
// Build columns - ONLY depends on fields, NOT selection state // Build columns - ONLY depends on fields, NOT selection state
// Selection state is handled by isolated HeaderCheckbox component // Selection state is handled by isolated HeaderCheckbox component
const columns = useMemo<ColumnDef<RowData>[]>(() => { const columns = useMemo<ColumnDef<RowData>[]>(() => {
@@ -2034,9 +2301,21 @@ export const ValidationTable = () => {
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false; const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each'; const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height'; const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
const isDefaultValueColumn = field.key === 'tax_cat' || field.key === 'ship_restrictions';
const isNameColumn = field.key === 'name';
// Determine which header component to render // Determine which header component to render
const renderHeader = () => { const renderHeader = () => {
if (isNameColumn) {
return (
<NameColumnHeader
label={field.label}
isRequired={isRequired}
isSticky={nameColumnSticky}
onToggleSticky={toggleNameColumnSticky}
/>
);
}
if (isPriceColumn) { if (isPriceColumn) {
return ( return (
<PriceColumnHeader <PriceColumnHeader
@@ -2055,6 +2334,15 @@ export const ValidationTable = () => {
/> />
); );
} }
if (isDefaultValueColumn) {
return (
<DefaultValueColumnHeader
fieldKey={field.key as 'tax_cat' | 'ship_restrictions'}
label={field.label}
isRequired={isRequired}
/>
);
}
return ( return (
<div className="flex items-center gap-1 truncate"> <div className="flex items-center gap-1 truncate">
<span className="truncate">{field.label}</span> <span className="truncate">{field.label}</span>
@@ -2073,7 +2361,7 @@ export const ValidationTable = () => {
}); });
return [selectionColumn, templateColumn, ...dataColumns]; return [selectionColumn, templateColumn, ...dataColumns];
}, [fields]); // CRITICAL: No selection-related deps! }, [fields, nameColumnSticky, toggleNameColumnSticky]); // Added nameColumnSticky dependencies
// Calculate total table width for horizontal scrolling // Calculate total table width for horizontal scrolling
const totalTableWidth = useMemo(() => { const totalTableWidth = useMemo(() => {
@@ -2109,20 +2397,31 @@ export const ValidationTable = () => {
> >
{columns.map((column, index) => { {columns.map((column, index) => {
const isNameColumn = column.id === 'name'; const isNameColumn = column.id === 'name';
// Determine sticky behavior for header name column
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
const stickyRight = shouldBeSticky && stickyDirection === 'right';
return ( return (
<div <div
key={column.id || index} key={column.id || index}
className={cn( className={cn(
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0", "px-3 flex items-center text-left text-sm font-medium text-muted-foreground",
// Sticky header needs solid background matching the row's bg-muted/50 appearance // Use box-shadow for right border - renders more consistently
isNameColumn && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]", "shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
isNameColumn && isScrolledHorizontally && "lg:shadow-md", // Sticky header - only when enabled and scrolled appropriately
shouldBeSticky && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
// Directional shadow on the outside edge where content scrolls behind (combined with border shadow)
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
)} )}
style={{ style={{
width: column.size || 150, width: column.size || 150,
minWidth: column.size || 150, minWidth: column.size || 150,
flexShrink: 0, flexShrink: 0,
...(isNameColumn && { left: NAME_COLUMN_STICKY_LEFT }), // Position sticky left or right based on scroll direction
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
...(stickyRight && { right: 0 }),
}} }}
> >
{typeof column.header === 'function' {typeof column.header === 'function'
@@ -2159,7 +2458,8 @@ export const ValidationTable = () => {
columns={columns} columns={columns}
fields={fields} fields={fields}
totalRowCount={rowCount} totalRowCount={rowCount}
isScrolledHorizontally={isScrolledHorizontally} nameColumnSticky={nameColumnSticky}
stickyDirection={stickyDirection}
/> />
); );
})} })}

View File

@@ -10,7 +10,7 @@
*/ */
import { useMemo, useCallback, useState } from 'react'; import { useMemo, useCallback, useState } from 'react';
import { Search, Plus, FolderPlus, Edit3 } from 'lucide-react'; import { Search, Plus, FolderPlus, Edit3, X } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -24,6 +24,7 @@ import {
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '../../../CreateProductCategoryDialog'; import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '../../../CreateProductCategoryDialog';
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'; import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog';
import { useTemplateManagement } from '../hooks/useTemplateManagement'; import { useTemplateManagement } from '../hooks/useTemplateManagement';
import { SaveSessionButton } from '../../../components/SaveSessionDialog';
interface ValidationToolbarProps { interface ValidationToolbarProps {
rowCount: number; rowCount: number;
@@ -39,6 +40,38 @@ export const ValidationToolbar = ({
const filters = useFilters(); const filters = useFilters();
const fields = useFields(); const fields = useFields();
// Compute filtered count when filtering is active
const filteredCount = useMemo(() => {
const isFiltering = filters.searchText || filters.showErrorsOnly;
if (!isFiltering) return rowCount;
const { rows, errors } = useValidationStore.getState();
let count = 0;
rows.forEach((row, index) => {
// Apply search filter
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matches = Object.values(row).some((value) =>
String(value ?? '').toLowerCase().includes(searchLower)
);
if (!matches) return;
}
// Apply errors-only filter
if (filters.showErrorsOnly) {
const rowErrors = errors.get(index);
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
}
count++;
});
return count;
}, [filters.searchText, filters.showErrorsOnly, rowCount]);
const isFiltering = filters.searchText || filters.showErrorsOnly;
// State for the product search template dialog // State for the product search template dialog
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
@@ -112,15 +145,30 @@ export const ValidationToolbar = ({
placeholder="Filter products..." placeholder="Filter products..."
value={filters.searchText} value={filters.searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="pl-9" className={filters.searchText ? "pl-9 pr-8" : "pl-9"}
/> />
{filters.searchText && (
<button
type="button"
onClick={() => setSearchText('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div> </div>
{/* Product count */} {/* Product count */}
<span className="text-sm text-muted-foreground">{rowCount} products</span> <span className="text-sm text-muted-foreground">
{isFiltering ? `${filteredCount} of ${rowCount} products shown` : `${rowCount} products`}
</span>
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
{/* Save session */}
<SaveSessionButton />
{/* Add row */} {/* Add row */}
<Button variant="outline" size="sm" onClick={() => handleAddRow()}> <Button variant="outline" size="sm" onClick={() => handleAddRow()}>
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />

View File

@@ -10,7 +10,7 @@
* This dramatically improves performance for 100+ option lists. * This dramatically improves performance for 100+ option lists.
*/ */
import { useState, useCallback, useRef, memo } from 'react'; import { useState, useCallback, useRef, useMemo, memo } from 'react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'; import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -59,6 +59,7 @@ const ComboboxCellComponent = ({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isLoadingOptions, setIsLoadingOptions] = useState(false); const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const hasFetchedRef = useRef(false); const hasFetchedRef = useRef(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Get store state for coordinating with popover close behavior // Get store state for coordinating with popover close behavior
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
@@ -78,6 +79,10 @@ const ComboboxCellComponent = ({
if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) { if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) {
return; return;
} }
// Reset scroll position when opening
if (isOpen && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
setOpen(isOpen); setOpen(isOpen);
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) { if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true; hasFetchedRef.current = true;
@@ -90,6 +95,13 @@ const ComboboxCellComponent = ({
[onFetchOptions, options.length, cellPopoverClosedAt] [onFetchOptions, options.length, cellPopoverClosedAt]
); );
// Reset scroll position when search filters the list
const handleSearchChange = useCallback(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
}, []);
// Handle selection // Handle selection
const handleSelect = useCallback( const handleSelect = useCallback(
(selectedValue: string) => { (selectedValue: string) => {
@@ -105,6 +117,11 @@ const ComboboxCellComponent = ({
e.currentTarget.scrollTop += e.deltaY; e.currentTarget.scrollTop += e.deltaY;
}, []); }, []);
// Sort options alphabetically by label for consistent display
const sortedOptions = useMemo(() => {
return [...options].sort((a, b) => a.label.localeCompare(b.label));
}, [options]);
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<Popover open={open} onOpenChange={handleOpenChange}> <Popover open={open} onOpenChange={handleOpenChange}>
@@ -130,7 +147,10 @@ const ComboboxCellComponent = ({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start"> <PopoverContent className="w-[250px] p-0" align="start">
<Command> <Command>
<CommandInput placeholder={`Search ${field.label.toLowerCase()}...`} /> <CommandInput
placeholder={`Search ${field.label.toLowerCase()}...`}
onValueChange={handleSearchChange}
/>
<CommandList> <CommandList>
{isLoadingOptions ? ( {isLoadingOptions ? (
<div className="flex items-center justify-center py-6"> <div className="flex items-center justify-center py-6">
@@ -140,11 +160,12 @@ const ComboboxCellComponent = ({
<> <>
<CommandEmpty>No {field.label.toLowerCase()} found.</CommandEmpty> <CommandEmpty>No {field.label.toLowerCase()} found.</CommandEmpty>
<div <div
ref={scrollContainerRef}
className="max-h-[200px] overflow-y-auto overscroll-contain" className="max-h-[200px] overflow-y-auto overscroll-contain"
onWheel={handleWheel} onWheel={handleWheel}
> >
<CommandGroup> <CommandGroup>
{options.map((option) => ( {sortedOptions.map((option) => (
<CommandItem <CommandItem
key={option.value} key={option.value}
value={option.label} // cmdk filters by this value value={option.label} // cmdk filters by this value

View File

@@ -3,9 +3,14 @@
* *
* Editable input cell for text, numbers, and price values. * Editable input cell for text, numbers, and price values.
* Memoized to prevent unnecessary re-renders when parent table updates. * Memoized to prevent unnecessary re-renders when parent table updates.
*
* PRICE PRECISION: For price fields, we store FULL precision internally
* (e.g., "3.625") but display rounded to 2 decimals when not focused.
* This allows calculations like 2x to use full precision while showing
* user-friendly rounded values in the UI.
*/ */
import { useState, useCallback, useEffect, useRef, memo } from 'react'; import { useState, useCallback, useEffect, useRef, memo, useMemo } from 'react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { import {
@@ -21,6 +26,17 @@ import type { ValidationError } from '../../store/types';
import { ErrorType } from '../../store/types'; import { ErrorType } from '../../store/types';
import { useValidationStore } from '../../store/validationStore'; import { useValidationStore } from '../../store/validationStore';
/**
* Format a price value for display (2 decimal places)
* Returns the original string if it's not a valid number
*/
const formatPriceForDisplay = (value: string): string => {
if (!value) return value;
const num = parseFloat(value);
if (isNaN(num)) return value;
return num.toFixed(2);
};
/** Time window (ms) during which this cell should not focus after a popover closes */ /** Time window (ms) during which this cell should not focus after a popover closes */
const POPOVER_CLOSE_DELAY = 150; const POPOVER_CLOSE_DELAY = 150;
@@ -43,10 +59,14 @@ const InputCellComponent = ({
errors, errors,
onBlur, onBlur,
}: InputCellProps) => { }: InputCellProps) => {
// Store the full precision value internally
const [localValue, setLocalValue] = useState(String(value ?? '')); const [localValue, setLocalValue] = useState(String(value ?? ''));
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Check if this is a price field
const isPriceField = 'price' in field.fieldType && field.fieldType.price;
// Get store state for coordinating with popover close behavior // Get store state for coordinating with popover close behavior
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
@@ -57,6 +77,14 @@ const InputCellComponent = ({
} }
}, [value, isFocused]); }, [value, isFocused]);
// For price fields: show formatted value when not focused, full precision when focused
const displayValue = useMemo(() => {
if (isPriceField && !isFocused && localValue) {
return formatPriceForDisplay(localValue);
}
return localValue;
}, [isPriceField, isFocused, localValue]);
// PERFORMANCE: Only update local state while typing, NOT the store // PERFORMANCE: Only update local state while typing, NOT the store
// The store is updated on blur, which prevents thousands of subscription // The store is updated on blur, which prevents thousands of subscription
// checks per keystroke // checks per keystroke
@@ -86,23 +114,13 @@ const InputCellComponent = ({
}, [cellPopoverClosedAt]); }, [cellPopoverClosedAt]);
// Update store only on blur - this is when validation runs too // Update store only on blur - this is when validation runs too
// Round price fields to 2 decimal places // IMPORTANT: We store FULL precision for price fields to allow accurate calculations
// The display formatting happens separately via displayValue
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setIsFocused(false); setIsFocused(false);
// Store the full precision value - no rounding here
let valueToSave = localValue; onBlur(localValue);
}, [localValue, onBlur]);
// Round price fields to 2 decimal places
if ('price' in field.fieldType && field.fieldType.price && localValue) {
const numValue = parseFloat(localValue);
if (!isNaN(numValue)) {
valueToSave = numValue.toFixed(2);
setLocalValue(valueToSave);
}
}
onBlur(valueToSave);
}, [localValue, onBlur, field.fieldType]);
// Process errors - show icon only for non-required errors when field has value // Process errors - show icon only for non-required errors when field has value
// Don't show error icon while user is actively editing (focused) // Don't show error icon while user is actively editing (focused)
@@ -129,7 +147,7 @@ const InputCellComponent = ({
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
ref={inputRef} ref={inputRef}
value={localValue} value={displayValue}
onChange={handleChange} onChange={handleChange}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}

View File

@@ -16,7 +16,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { X, Loader2, Sparkles, AlertCircle, Check, ChevronDown, ChevronUp } from 'lucide-react'; import { X, Loader2, Sparkles, AlertCircle, Check } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Field, SelectOption } from '../../../../types'; import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types'; import type { ValidationError } from '../../store/types';
@@ -55,9 +55,10 @@ interface MultilineInputProps {
const MultilineInputComponent = ({ const MultilineInputComponent = ({
field, field,
value, value,
productIndex,
isValidating, isValidating,
errors, errors,
onChange, onChange: _onChange, // Unused - onBlur handles both update and validation
onBlur, onBlur,
aiSuggestion, aiSuggestion,
isAiValidating, isAiValidating,
@@ -68,10 +69,25 @@ const MultilineInputComponent = ({
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null); const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false); const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
const [editedSuggestion, setEditedSuggestion] = useState(''); const [editedSuggestion, setEditedSuggestion] = useState('');
const [popoverWidth, setPopoverWidth] = useState(400);
const [popoverHeight, setPopoverHeight] = useState<number | undefined>(undefined);
const resizeContainerRef = useRef<HTMLDivElement>(null);
const cellRef = useRef<HTMLDivElement>(null); const cellRef = useRef<HTMLDivElement>(null);
const preventReopenRef = useRef(false); const preventReopenRef = useRef(false);
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes // Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
const intentionalCloseRef = useRef(false); const intentionalCloseRef = useRef(false);
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
// Tracks the value when popover opened, to detect actual changes
const initialEditValueRef = useRef('');
// Ref for the right-side header+issues area to measure its height for left-side spacer
const aiHeaderRef = useRef<HTMLDivElement>(null);
const [aiHeaderHeight, setAiHeaderHeight] = useState(0);
// Get the product name for this row from the store
const productName = useValidationStore(
(s) => s.rows.find((row) => row.__index === productIndex)?.name as string | undefined
);
// Get store state and actions for coordinating popover close behavior across cells // Get store state and actions for coordinating popover close behavior across cells
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
@@ -112,11 +128,92 @@ const MultilineInputComponent = ({
} }
}, [aiSuggestion?.suggestion]); }, [aiSuggestion?.suggestion]);
// Auto-resize a textarea to fit its content
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}, []);
// Auto-resize main textarea when value changes or popover opens
useEffect(() => {
if (popoverOpen) {
// Small delay to ensure textarea is rendered
requestAnimationFrame(() => {
autoResizeTextarea(mainTextareaRef.current);
});
}
}, [popoverOpen, editValue, autoResizeTextarea]);
// Auto-resize suggestion textarea when expanded/visible or value changes
useEffect(() => {
if (aiSuggestionExpanded || (popoverOpen && hasAiSuggestion)) {
requestAnimationFrame(() => {
autoResizeTextarea(suggestionTextareaRef.current);
});
}
}, [aiSuggestionExpanded, popoverOpen, hasAiSuggestion, editedSuggestion, autoResizeTextarea]);
// Set initial popover height to fit the tallest textarea content, capped by window height.
// Only applies on desktop (lg breakpoint) — mobile uses natural flow with individually resizable textareas.
useEffect(() => {
if (!popoverOpen) { setPopoverHeight(undefined); return; }
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
if (!isDesktop) { setPopoverHeight(undefined); return; }
const rafId = requestAnimationFrame(() => {
const main = mainTextareaRef.current;
const suggestion = suggestionTextareaRef.current;
const container = resizeContainerRef.current;
if (!container) return;
// Get textarea natural content heights
const mainScrollH = main ? main.scrollHeight : 0;
const suggestionScrollH = suggestion ? suggestion.scrollHeight : 0;
const tallestTextarea = Math.max(mainScrollH, suggestionScrollH);
// Measure chrome for both columns (everything except the textarea)
const leftChrome = main ? (main.closest('[data-col="left"]')?.scrollHeight ?? 0) - main.offsetHeight : 0;
const rightChrome = suggestion ? (suggestion.closest('[data-col="right"]')?.scrollHeight ?? 0) - suggestion.offsetHeight : 0;
const chrome = Math.max(leftChrome, rightChrome);
const naturalHeight = chrome + tallestTextarea;
const maxHeight = Math.floor(window.innerHeight * 0.7);
setPopoverHeight(Math.max(Math.min(naturalHeight, maxHeight), 200));
});
return () => cancelAnimationFrame(rafId);
}, [popoverOpen]);
// Measure the right-side header+issues area so the left spacer matches.
// Uses rAF because Radix portals mount asynchronously, so the ref is null on the first synchronous run.
useEffect(() => {
if (!popoverOpen || !hasAiSuggestion) { setAiHeaderHeight(0); return; }
let observer: ResizeObserver | null = null;
const rafId = requestAnimationFrame(() => {
const el = aiHeaderRef.current;
if (!el) return;
observer = new ResizeObserver(([entry]) => {
setAiHeaderHeight(entry.contentRect.height-7);
});
observer.observe(el);
});
return () => {
cancelAnimationFrame(rafId);
observer?.disconnect();
};
}, [popoverOpen, hasAiSuggestion]);
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside) // Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
const wasPopoverRecentlyClosed = useCallback(() => { const wasPopoverRecentlyClosed = useCallback(() => {
return Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY; return Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY;
}, [cellPopoverClosedAt]); }, [cellPopoverClosedAt]);
// Calculate and set popover width based on cell width
const updatePopoverWidth = useCallback(() => {
if (cellRef.current) {
setPopoverWidth(Math.max(cellRef.current.offsetWidth, 200));
}
}, []);
// Handle trigger click to toggle the popover // Handle trigger click to toggle the popover
const handleTriggerClick = useCallback( const handleTriggerClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -126,7 +223,7 @@ const MultilineInputComponent = ({
preventReopenRef.current = false; preventReopenRef.current = false;
return; return;
} }
// Block opening if another popover was just closed // Block opening if another popover was just closed
if (wasPopoverRecentlyClosed()) { if (wasPopoverRecentlyClosed()) {
e.preventDefault(); e.preventDefault();
@@ -136,23 +233,26 @@ const MultilineInputComponent = ({
// Only process if not already open // Only process if not already open
if (!popoverOpen) { if (!popoverOpen) {
updatePopoverWidth();
setPopoverOpen(true); setPopoverOpen(true);
// Initialize edit value from the current display // Initialize edit value from the current display and track it for change detection
setEditValue(localDisplayValue || String(value ?? '')); const initValue = localDisplayValue || String(value ?? '');
setEditValue(initValue);
initialEditValueRef.current = initValue;
} }
}, },
[popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed] [popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed, updatePopoverWidth]
); );
// Handle immediate close of popover (used by close button and actions - intentional closes) // Handle immediate close of popover (used by close button and actions - intentional closes)
const handleClosePopover = useCallback(() => { const handleClosePopover = useCallback(() => {
// Only process if we have changes // Only process if the user actually changed the value
if (editValue !== value || editValue !== localDisplayValue) { if (editValue !== initialEditValueRef.current) {
// Update local display immediately // Update local display immediately
setLocalDisplayValue(editValue); setLocalDisplayValue(editValue);
// Queue up the change // onBlur handles both cell update and validation (don't call onChange first
onChange(editValue); // as it would update the store before onBlur can capture previousValue)
onBlur(editValue); onBlur(editValue);
} }
@@ -168,7 +268,7 @@ const MultilineInputComponent = ({
setTimeout(() => { setTimeout(() => {
preventReopenRef.current = false; preventReopenRef.current = false;
}, 100); }, 100);
}, [editValue, value, localDisplayValue, onChange, onBlur]); }, [editValue, onBlur]);
// Handle popover open/close (called by Radix for click-outside and escape key) // Handle popover open/close (called by Radix for click-outside and escape key)
const handlePopoverOpenChange = useCallback( const handlePopoverOpenChange = useCallback(
@@ -183,10 +283,10 @@ const MultilineInputComponent = ({
return; return;
} }
// This is a click-outside close - save changes and signal other cells // This is a click-outside close - only save if user actually changed the value
if (editValue !== value || editValue !== localDisplayValue) { if (editValue !== initialEditValueRef.current) {
setLocalDisplayValue(editValue); setLocalDisplayValue(editValue);
onChange(editValue); // onBlur handles both cell update and validation
onBlur(editValue); onBlur(editValue);
} }
@@ -205,28 +305,33 @@ const MultilineInputComponent = ({
if (wasPopoverRecentlyClosed()) { if (wasPopoverRecentlyClosed()) {
return; return;
} }
setEditValue(localDisplayValue || String(value ?? '')); updatePopoverWidth();
// Initialize edit value and track it for change detection
const initValue = localDisplayValue || String(value ?? '');
setEditValue(initValue);
initialEditValueRef.current = initValue;
setPopoverOpen(true); setPopoverOpen(true);
} }
}, },
[value, popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onChange, onBlur, setCellPopoverClosed] [popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onBlur, setCellPopoverClosed, updatePopoverWidth, value]
); );
// Handle direct input change // Handle direct input change
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditValue(e.target.value); setEditValue(e.target.value);
}, []); autoResizeTextarea(e.target);
}, [autoResizeTextarea]);
// Handle accepting the AI suggestion (possibly edited) // Handle accepting the AI suggestion (possibly edited)
const handleAcceptSuggestion = useCallback(() => { const handleAcceptSuggestion = useCallback(() => {
// Use the edited suggestion // Use the edited suggestion
setEditValue(editedSuggestion); setEditValue(editedSuggestion);
setLocalDisplayValue(editedSuggestion); setLocalDisplayValue(editedSuggestion);
onChange(editedSuggestion); // onBlur handles both cell update and validation
onBlur(editedSuggestion); onBlur(editedSuggestion);
onDismissAiSuggestion?.(); // Clear the suggestion after accepting onDismissAiSuggestion?.(); // Clear the suggestion after accepting
setAiSuggestionExpanded(false); setAiSuggestionExpanded(false);
}, [editedSuggestion, onChange, onBlur, onDismissAiSuggestion]); }, [editedSuggestion, onBlur, onDismissAiSuggestion]);
// Handle dismissing the AI suggestion // Handle dismissing the AI suggestion
const handleDismissSuggestion = useCallback(() => { const handleDismissSuggestion = useCallback(() => {
@@ -243,7 +348,7 @@ const MultilineInputComponent = ({
return ( return (
<div className="w-full relative" ref={cellRef}> <div className="w-full relative" ref={cellRef}>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}> <Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange} modal>
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={300}> <Tooltip delayDuration={300}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -270,9 +375,13 @@ const MultilineInputComponent = ({
if (wasPopoverRecentlyClosed()) { if (wasPopoverRecentlyClosed()) {
return; return;
} }
updatePopoverWidth();
setAiSuggestionExpanded(true); setAiSuggestionExpanded(true);
setPopoverOpen(true); setPopoverOpen(true);
setEditValue(localDisplayValue || String(value ?? '')); // Initialize edit value and track it for change detection
const initValue = localDisplayValue || String(value ?? '');
setEditValue(initValue);
initialEditValueRef.current = initValue;
}} }}
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors" className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors"
title="View AI suggestion" title="View AI suggestion"
@@ -302,14 +411,30 @@ const MultilineInputComponent = ({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<PopoverContent <PopoverContent
className="p-0 shadow-lg rounded-md" className="p-0 shadow-lg rounded-md max-lg:!w-[95vw]"
style={{ width: Math.max(cellRef.current?.offsetWidth || 400, 400) }} style={{ width: hasAiSuggestion ? popoverWidth * 2 : popoverWidth }}
align="start" align="start"
side="bottom" side="bottom"
alignOffset={0} alignOffset={0}
sideOffset={-65} sideOffset={-65}
ref={(node) => {
// Override Radix popper positioning to center on screen when AI suggestion is showing
if (node && hasAiSuggestion) {
const wrapper = node.parentElement;
if (wrapper?.hasAttribute('data-radix-popper-content-wrapper')) {
wrapper.style.position = 'fixed';
wrapper.style.top = '50%';
wrapper.style.left = '50%';
wrapper.style.transform = 'translate(-50%, -50%)';
}
}
}}
> >
<div className="flex flex-col"> <div
ref={resizeContainerRef}
className="flex flex-col lg:flex-row items-stretch lg:resize-y lg:overflow-auto lg:min-h-[120px] max-h-[85vh] overflow-y-auto lg:max-h-none"
style={popoverHeight ? { height: popoverHeight } : undefined}
>
{/* Close button */} {/* Close button */}
<Button <Button
size="icon" size="icon"
@@ -321,93 +446,115 @@ const MultilineInputComponent = ({
</Button> </Button>
{/* Main textarea */} {/* Main textarea */}
<div data-col="left" className="flex flex-col min-h-0 w-full lg:w-1/2">
<div className={cn(hasAiSuggestion ? 'px-3 py-2 bg-accent' : '', 'flex flex-col flex-1 min-h-0')}>
{/* Product name - shown inline on mobile, in measured spacer on desktop */}
{hasAiSuggestion && productName && (
<div className="flex-shrink-0 flex flex-col lg:hidden px-1 mb-2">
<div className="text-sm font-medium text-foreground mb-1">Editing description for:</div>
<div className="text-md font-semibold text-foreground line-clamp-1">{productName}</div>
</div>
)}
{hasAiSuggestion && aiHeaderHeight > 0 && (
<div className="flex-shrink-0 hidden lg:flex items-start" style={{ height: aiHeaderHeight }}>
{productName && (
<div className="flex flex-col">
<div className="text-sm font-medium text-foreground px-1 mb-1">Editing description for:</div>
<div className="text-md font-semibold text-foreground line-clamp-1 px-1">{productName}</div>
</div>
)}
</div>
)}
{hasAiSuggestion && <div className="text-sm mb-1 font-medium flex items-center gap-2 flex-shrink-0">
Current Description:
</div>}
{/* Dynamic spacer matching the right-side header+issues height */}
<Textarea <Textarea
ref={mainTextareaRef}
value={editValue} value={editValue}
onChange={handleChange} onChange={handleChange}
onWheel={handleTextareaWheel} onWheel={handleTextareaWheel}
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-y" className={cn("overflow-y-auto overscroll-contain text-sm lg:flex-1 resize-y lg:resize-none bg-white min-h-[120px] lg:min-h-0")}
placeholder={`Enter ${field.label || 'text'}...`} placeholder={`Enter ${field.label || 'text'}...`}
autoFocus autoFocus
/> />
{hasAiSuggestion && <div className="h-[43px] flex-shrink-0 hidden lg:block" />}
</div></div>
{/* AI Suggestion section */} {/* AI Suggestion section */}
{hasAiSuggestion && ( {hasAiSuggestion && (
<div className="border-t border-purple-200 dark:border-purple-800 bg-purple-50/80 dark:bg-purple-950/30"> <div data-col="right" className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
{/* Collapsed header - always visible */} {/* Measured header + issues area (mirrored as spacer on the left) */}
<button <div ref={aiHeaderRef} className="flex-shrink-0">
type="button" {/* Header */}
onClick={() => setAiSuggestionExpanded(!aiSuggestionExpanded)} <div className="w-full flex items-center justify-between px-3 py-2">
className="w-full flex items-center justify-between px-3 py-2 hover:bg-purple-100/50 dark:hover:bg-purple-900/30 transition-colors"
>
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
AI Suggestion
</span>
<span className="text-xs text-purple-500 dark:text-purple-400">
({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
</span>
</div>
{aiSuggestionExpanded ? (
<ChevronUp className="h-4 w-4 text-purple-400" />
) : (
<ChevronDown className="h-4 w-4 text-purple-400" />
)}
</button>
{/* Expanded content */}
{aiSuggestionExpanded && (
<div className="px-3 pb-3 space-y-3">
{/* Issues list */}
{aiIssues.length > 0 && (
<div className="flex flex-col gap-1">
{aiIssues.map((issue, index) => (
<div
key={index}
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
>
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
<span>{issue}</span>
</div>
))}
</div>
)}
{/* Editable suggestion */}
<div>
<div className="text-xs text-purple-500 dark:text-purple-400 mb-1 font-medium">
Suggested (editable):
</div>
<Textarea
value={editedSuggestion}
onChange={(e) => setEditedSuggestion(e.target.value)}
onWheel={handleTextareaWheel}
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Sparkles className="h-3.5 w-3.5 text-purple-500" />
size="sm" <span className="text-xs font-medium text-purple-600 dark:text-purple-400">
variant="outline" AI Suggestion
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400" </span>
onClick={handleAcceptSuggestion} <span className="text-xs text-purple-500 dark:text-purple-400">
> ({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
<Check className="h-3 w-3 mr-1" /> </span>
Replace With Suggestion
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
onClick={handleDismissSuggestion}
>
Dismiss
</Button>
</div> </div>
</div> </div>
)}
{/* Issues list */}
{aiIssues.length > 0 && (
<div className="flex flex-col gap-1 px-3 pb-3">
{aiIssues.map((issue, index) => (
<div
key={index}
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
>
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
<span>{issue}</span>
</div>
))}
</div>
)}
</div>
{/* Content */}
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
{/* Editable suggestion */}
<div className="flex flex-col flex-1">
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0">
Suggested (editable):
</div>
<Textarea
ref={suggestionTextareaRef}
value={editedSuggestion}
onChange={(e) => {
setEditedSuggestion(e.target.value);
autoResizeTextarea(e.target);
}}
onWheel={handleTextareaWheel}
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y lg:resize-none lg:flex-1 min-h-[120px] lg:min-h-0"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
onClick={handleAcceptSuggestion}
>
<Check className="h-3 w-3 mr-1" />
Replace With Suggestion
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
onClick={handleDismissSuggestion}
>
Ignore
</Button>
</div>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -65,6 +65,7 @@ const SelectCellComponent = ({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isFetchingOptions, setIsFetchingOptions] = useState(false); const [isFetchingOptions, setIsFetchingOptions] = useState(false);
const hasFetchedRef = useRef(false); const hasFetchedRef = useRef(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Get store state for coordinating with popover close behavior // Get store state for coordinating with popover close behavior
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
@@ -98,11 +99,22 @@ const SelectCellComponent = ({
setIsFetchingOptions(false); setIsFetchingOptions(false);
} }
} }
// Reset scroll position when opening
if (isOpen && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
setOpen(isOpen); setOpen(isOpen);
}, },
[onFetchOptions, options.length, cellPopoverClosedAt] [onFetchOptions, options.length, cellPopoverClosedAt]
); );
// Reset scroll position when search filters the list
const handleSearchChange = useCallback(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
}, []);
// Handle selection // Handle selection
const handleSelect = useCallback( const handleSelect = useCallback(
(selectedValue: string) => { (selectedValue: string) => {
@@ -118,6 +130,11 @@ const SelectCellComponent = ({
e.currentTarget.scrollTop += e.deltaY; e.currentTarget.scrollTop += e.deltaY;
}, []); }, []);
// Sort options alphabetically by label for consistent display
const sortedOptions = useMemo(() => {
return [...options].sort((a, b) => a.label.localeCompare(b.label));
}, [options]);
// Find display label for current value // Find display label for current value
// IMPORTANT: We need to match against both string and number value types // IMPORTANT: We need to match against both string and number value types
const displayLabel = useMemo(() => { const displayLabel = useMemo(() => {
@@ -182,7 +199,11 @@ const SelectCellComponent = ({
sideOffset={4} sideOffset={4}
> >
<Command shouldFilter={true}> <Command shouldFilter={true}>
<CommandInput placeholder="Search..." className="h-9" /> <CommandInput
placeholder="Search..."
className="h-9"
onValueChange={handleSearchChange}
/>
<CommandList> <CommandList>
{isLoadingOptions ? ( {isLoadingOptions ? (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
@@ -192,11 +213,12 @@ const SelectCellComponent = ({
<> <>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>No results found.</CommandEmpty>
<div <div
ref={scrollContainerRef}
className="max-h-[200px] overflow-y-auto overscroll-contain" className="max-h-[200px] overflow-y-auto overscroll-contain"
onWheel={handleWheel} onWheel={handleWheel}
> >
<CommandGroup> <CommandGroup>
{options.map((option) => ( {sortedOptions.map((option) => (
<CommandItem <CommandItem
key={option.value} key={option.value}
value={option.label} value={option.label}

View File

@@ -15,6 +15,7 @@ import {
useTemplates, useTemplates,
useTemplatesLoading, useTemplatesLoading,
useTemplateState, useTemplateState,
useFields,
} from '../store/selectors'; } from '../store/selectors';
import { toast } from 'sonner'; import { toast } from 'sonner';
import config from '@/config'; import config from '@/config';
@@ -41,6 +42,7 @@ export const useTemplateManagement = () => {
const templates = useTemplates(); const templates = useTemplates();
const templatesLoading = useTemplatesLoading(); const templatesLoading = useTemplatesLoading();
const templateState = useTemplateState(); const templateState = useTemplateState();
const fields = useFields();
// Store actions // Store actions
const setTemplates = useValidationStore((state) => state.setTemplates); const setTemplates = useValidationStore((state) => state.setTemplates);
@@ -101,9 +103,17 @@ export const useTemplateManagement = () => {
return; return;
} }
// Extract template fields // Extract template fields, excluding those with empty/null/undefined values
// This preserves existing row values when template has no value for a field
const templateFields = Object.entries(template).filter( const templateFields = Object.entries(template).filter(
([key]) => !TEMPLATE_EXCLUDE_FIELDS.includes(key) ([key, value]) => {
if (TEMPLATE_EXCLUDE_FIELDS.includes(key)) return false;
// Skip empty values so existing row data is preserved
if (value === null || value === undefined || value === '') return false;
// Skip empty arrays
if (Array.isArray(value) && value.length === 0) return false;
return true;
}
); );
// Apply template to each row // Apply template to each row
@@ -295,6 +305,7 @@ export const useTemplateManagement = () => {
/** /**
* Get display text for a template (e.g., "Brand - Product Type") * Get display text for a template (e.g., "Brand - Product Type")
* Looks up company name from field options instead of showing ID
*/ */
const getTemplateDisplayText = useCallback( const getTemplateDisplayText = useCallback(
(templateId: string | null): string => { (templateId: string | null): string => {
@@ -303,12 +314,20 @@ export const useTemplateManagement = () => {
const template = templates.find((t) => t.id.toString() === templateId); const template = templates.find((t) => t.id.toString() === templateId);
if (!template) return ''; if (!template) return '';
// Return "Brand - Product Type" format // Look up company name from field options
const company = template.company || 'Unknown'; const companyField = fields.find((f) => f.key === 'company');
const companyOptions = companyField?.fieldType?.type === 'select'
? companyField.fieldType.options
: [];
const companyOption = companyOptions?.find(
(opt) => String(opt.value) === String(template.company)
);
const companyName = companyOption?.label || template.company || 'Unknown';
const productType = template.product_type || 'Unknown'; const productType = template.product_type || 'Unknown';
return `${company} - ${productType}`; return `${companyName} - ${productType}`;
}, },
[templates] [templates, fields]
); );
return { return {

View File

@@ -185,22 +185,14 @@ export const useValidationActions = () => {
* *
* With 100 rows × 30 fields, the old approach would trigger ~3000 individual * With 100 rows × 30 fields, the old approach would trigger ~3000 individual
* set() calls, each cloning the entire errors Map. This approach triggers ONE. * set() calls, each cloning the entire errors Map. This approach triggers ONE.
*
* Also handles:
* - Rounding currency fields to 2 decimal places
*/ */
const validateAllRows = useCallback(async () => { const validateAllRows = useCallback(async () => {
const { rows: currentRows, fields: currentFields, errors: existingErrors, setBulkValidationResults, updateCell: updateCellAction } = useValidationStore.getState(); const { rows: currentRows, fields: currentFields, errors: existingErrors, setBulkValidationResults } = useValidationStore.getState();
// Collect ALL errors in plain JS Maps (no Immer overhead) // Collect ALL errors in plain JS Maps (no Immer overhead)
const allErrors = new Map<number, Record<string, ValidationError[]>>(); const allErrors = new Map<number, Record<string, ValidationError[]>>();
const allStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>(); const allStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>();
// Identify price fields for currency rounding
const priceFields = currentFields.filter((f: Field<string>) =>
'price' in f.fieldType && f.fieldType.price
).map((f: Field<string>) => f.key);
// Process all rows - collect errors without touching the store // Process all rows - collect errors without touching the store
for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) { for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) {
const row = currentRows[rowIndex]; const row = currentRows[rowIndex];
@@ -221,20 +213,11 @@ export const useValidationActions = () => {
}); });
} }
// Round currency fields to 2 decimal places on initial load // NOTE: We no longer round price fields on initial load.
for (const priceFieldKey of priceFields) { // Full precision is preserved internally (e.g., "3.625") for accurate calculations.
const value = row[priceFieldKey]; // - Display: InputCell shows 2 decimals when not focused
if (value !== undefined && value !== null && value !== '') { // - Calculations: 2x button uses full precision
const numValue = parseFloat(String(value)); // - API submission: getCleanedData() formats to 2 decimals
if (!isNaN(numValue)) {
const rounded = numValue.toFixed(2);
if (String(value) !== rounded) {
// Update the cell with rounded value (batched later)
updateCellAction(rowIndex, priceFieldKey, rounded);
}
}
}
}
// Validate each field // Validate each field
for (const field of currentFields) { for (const field of currentFields) {

View File

@@ -8,7 +8,7 @@
* 4. Renders the ValidationContainer once initialized * 4. Renders the ValidationContainer once initialized
*/ */
import { useEffect, useRef, useDeferredValue } from 'react'; import { useEffect, useRef, useDeferredValue, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useValidationStore } from './store/validationStore'; import { useValidationStore } from './store/validationStore';
import { useInitPhase, useIsReady } from './store/selectors'; import { useInitPhase, useIsReady } from './store/selectors';
@@ -24,6 +24,36 @@ import config from '@/config';
import type { ValidationStepProps } from './store/types'; import type { ValidationStepProps } from './store/types';
import type { Field, SelectOption } from '../../types'; import type { Field, SelectOption } from '../../types';
/**
* Create a fingerprint of the data to detect changes.
* This is used to determine if we need to re-initialize the store
* when navigating back to this step with potentially modified data.
*/
const createDataFingerprint = (data: Record<string, unknown>[]): string => {
// Sample key fields that are likely to change when user modifies data in previous steps
const keyFields = ['supplier', 'company', 'line', 'subline', 'name', 'upc', 'item_number'];
// Create a simple hash from first few rows + last row + count
const sampleSize = Math.min(3, data.length);
const samples: string[] = [];
// First few rows
for (let i = 0; i < sampleSize; i++) {
const row = data[i];
const values = keyFields.map(k => String(row[k] ?? '')).join('|');
samples.push(values);
}
// Last row (if different from samples)
if (data.length > sampleSize) {
const lastRow = data[data.length - 1];
const values = keyFields.map(k => String(lastRow[k] ?? '')).join('|');
samples.push(values);
}
return `${data.length}:${samples.join(';;')}`;
};
/** /**
* Fetch field options from the API * Fetch field options from the API
*/ */
@@ -105,6 +135,7 @@ export const ValidationStep = ({
const templatesLoadedRef = useRef(false); const templatesLoadedRef = useRef(false);
const upcValidationStartedRef = useRef(false); const upcValidationStartedRef = useRef(false);
const fieldValidationStartedRef = useRef(false); const fieldValidationStartedRef = useRef(false);
const lastDataFingerprintRef = useRef<string | null>(null);
// Debug logging // Debug logging
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady); console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
@@ -132,12 +163,25 @@ export const ValidationStep = ({
retry: 2, retry: 2,
}); });
// Get current store state to check if we're returning to an already-initialized store // Create a fingerprint of the incoming data to detect changes
const storeRows = useValidationStore((state) => state.rows); const dataFingerprint = useMemo(() => createDataFingerprint(initialData), [initialData]);
// Initialize store with data // Initialize store with data
useEffect(() => { useEffect(() => {
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase); console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
console.log('[ValidationStep] Data fingerprint:', dataFingerprint, 'Last fingerprint:', lastDataFingerprintRef.current);
// Check if data has changed since last initialization
const dataHasChanged = lastDataFingerprintRef.current !== null && lastDataFingerprintRef.current !== dataFingerprint;
if (dataHasChanged) {
console.log('[ValidationStep] Data has changed - forcing re-initialization');
// Reset all refs to allow re-initialization
initStartedRef.current = false;
templatesLoadedRef.current = false;
upcValidationStartedRef.current = false;
fieldValidationStartedRef.current = false;
}
// Skip if already initialized (check both ref AND store state) // Skip if already initialized (check both ref AND store state)
// The ref prevents double-init within the same mount cycle // The ref prevents double-init within the same mount cycle
@@ -148,17 +192,16 @@ export const ValidationStep = ({
} }
// IMPORTANT: Skip initialization if we're returning to an already-ready store // IMPORTANT: Skip initialization if we're returning to an already-ready store
// This happens when navigating back from ImageUploadStep - the store still has // with the SAME data. This happens when navigating back from ImageUploadStep.
// all the validated data, so we don't need to re-run the initialization sequence. // We compare fingerprints to detect if the data has actually changed.
// We check that the store is 'ready' and has matching row count to avoid if (initPhase === 'ready' && !dataHasChanged && lastDataFingerprintRef.current === dataFingerprint) {
// false positives from stale store data. console.log('[ValidationStep] Skipping init - returning to already-ready store with same data');
if (initPhase === 'ready' && storeRows.length === initialData.length && storeRows.length > 0) {
console.log('[ValidationStep] Skipping init - returning to already-ready store with', storeRows.length, 'rows');
initStartedRef.current = true; initStartedRef.current = true;
return; return;
} }
initStartedRef.current = true; initStartedRef.current = true;
lastDataFingerprintRef.current = dataFingerprint;
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows'); console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
@@ -172,7 +215,7 @@ export const ValidationStep = ({
console.log('[ValidationStep] Calling initialize()'); console.log('[ValidationStep] Calling initialize()');
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file); initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
console.log('[ValidationStep] initialize() called'); console.log('[ValidationStep] initialize() called');
}, [initialData, file, initialize, initPhase, storeRows.length]); }, [initialData, file, initialize, initPhase, dataFingerprint]);
// Update fields when options are loaded // Update fields when options are loaded
// CRITICAL: Check store state (not ref) because initialize() resets the store // CRITICAL: Check store state (not ref) because initialize() resets the store

View File

@@ -215,6 +215,20 @@ export const useValidationStore = create<ValidationStore>()(
deleteRows: (rowIndexes: number[]) => { deleteRows: (rowIndexes: number[]) => {
set((state) => { set((state) => {
// Collect row IDs to remove from selection before deleting
const rowIdsToDelete = new Set<string>();
rowIndexes.forEach((index) => {
if (index >= 0 && index < state.rows.length) {
const rowId = state.rows[index].__index;
if (rowId) rowIdsToDelete.add(rowId);
}
});
// Clear these rows from selectedRows
rowIdsToDelete.forEach((rowId) => {
state.selectedRows.delete(rowId);
});
// Sort descending to delete from end first (preserves indices) // Sort descending to delete from end first (preserves indices)
const sorted = [...rowIndexes].sort((a, b) => b - a); const sorted = [...rowIndexes].sort((a, b) => b - a);
sorted.forEach((index) => { sorted.forEach((index) => {
@@ -949,9 +963,25 @@ export const useValidationStore = create<ValidationStore>()(
getCleanedData: (): CleanRowData[] => { getCleanedData: (): CleanRowData[] => {
const { rows } = get(); const { rows } = get();
// Price fields that should be formatted to 2 decimal places for API submission
const priceFields = ['msrp', 'cost_each'];
return rows.map((row) => { return rows.map((row) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __index, __template, __original, __corrected, __changes, __aiSupplemental, ...cleanRow } = row; const { __index, __template, __original, __corrected, __changes, __aiSupplemental, ...cleanRow } = row;
// Format price fields to 2 decimal places for API submission
// This ensures consistent precision while internal storage keeps full precision for calculations
for (const field of priceFields) {
const value = cleanRow[field];
if (value !== undefined && value !== null && value !== '') {
const num = parseFloat(String(value));
if (!isNaN(num)) {
cleanRow[field] = num.toFixed(2);
}
}
}
return cleanRow as CleanRowData; return cleanRow as CleanRowData;
}); });
}, },

View File

@@ -34,10 +34,16 @@ export function calculateEanCheckDigit(eanBody: string): number {
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } { export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
const value = rawValue ?? ''; const value = rawValue ?? '';
const str = typeof value === 'string' ? value.trim() : String(value); const originalStr = typeof value === 'string' ? value : String(value);
// Strip ALL whitespace (spaces, tabs, etc.) from UPC values - not just leading/trailing
const str = originalStr.replace(/\s+/g, '');
// Track if whitespace was stripped (this alone means we changed the value)
const whitespaceStripped = str !== originalStr;
if (str === '' || !NUMERIC_REGEX.test(str)) { if (str === '' || !NUMERIC_REGEX.test(str)) {
return { corrected: str, changed: false }; // Return stripped version even if not numeric (so non-numeric values still get spaces removed)
return { corrected: str, changed: whitespaceStripped };
} }
if (str.length === 11) { if (str.length === 11) {
@@ -49,15 +55,18 @@ export function correctUpcValue(rawValue: unknown): { corrected: string; changed
const body = str.slice(0, 11); const body = str.slice(0, 11);
const check = calculateUpcCheckDigit(body); const check = calculateUpcCheckDigit(body);
const corrected = `${body}${check}`; const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str }; // Changed if whitespace was stripped OR if check digit was corrected
return { corrected, changed: whitespaceStripped || corrected !== str };
} }
if (str.length === 13) { if (str.length === 13) {
const body = str.slice(0, 12); const body = str.slice(0, 12);
const check = calculateEanCheckDigit(body); const check = calculateEanCheckDigit(body);
const corrected = `${body}${check}`; const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str }; // Changed if whitespace was stripped OR if check digit was corrected
return { corrected, changed: whitespaceStripped || corrected !== str };
} }
return { corrected: str, changed: false }; // For other lengths, return stripped value
return { corrected: str, changed: whitespaceStripped };
} }

View File

@@ -10,6 +10,7 @@ export type SubmitOptions = {
targetEnvironment: "dev" | "prod" targetEnvironment: "dev" | "prod"
useTestDataSource: boolean useTestDataSource: boolean
skipApiSubmission?: boolean skipApiSubmission?: boolean
showNewProduct?: boolean
} }
export type RsiProps<T extends string> = { export type RsiProps<T extends string> = {

View File

@@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -2,7 +2,7 @@ const isDev = import.meta.env.DEV;
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
// Use proxy paths when on inventory domains to avoid CORS // Use proxy paths when on inventory domains to avoid CORS
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.acot.site' || window.location.hostname === 'acot.site'); const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.tools.acherryontop.com' || window.location.hostname === 'tools.acherryontop.com');
const liveDashboardConfig = { const liveDashboardConfig = {
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth', auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',

View File

@@ -0,0 +1,390 @@
/**
* Import Session Context
*
* Manages import session state across the product import flow,
* including autosave and named session management.
*/
import {
createContext,
useState,
useCallback,
useContext,
useRef,
useEffect,
ReactNode,
} from 'react';
import { AuthContext } from './AuthContext';
import {
autosaveSession,
createSession,
updateSession,
deleteSession,
} from '@/services/importSessionApi';
import type {
ImportSession,
ImportSessionData,
ImportSessionContextValue,
} from '@/types/importSession';
import { useValidationStore } from '@/components/product-import/steps/ValidationStep/store/validationStore';
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 seconds
const defaultContext: ImportSessionContextValue = {
sessionId: null,
sessionName: null,
isDirty: false,
lastSaved: null,
isSaving: false,
currentStep: 'validation',
setCurrentStep: () => {},
markDirty: () => {},
saveAsNamed: async () => { throw new Error('Not initialized'); },
save: async () => {},
forceSave: async () => {},
loadSession: () => {},
clearSession: () => {},
deleteSession: async () => {},
setDataGetter: () => {},
getSuggestedSessionName: () => null,
setGlobalSelections: () => {},
getGlobalSelections: () => undefined,
};
export const ImportSessionContext = createContext<ImportSessionContextValue>(defaultContext);
interface ImportSessionProviderProps {
children: ReactNode;
}
export function ImportSessionProvider({ children }: ImportSessionProviderProps) {
const { user } = useContext(AuthContext);
// Session state
const [sessionId, setSessionId] = useState<number | null>(null);
const [sessionName, setSessionName] = useState<string | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [currentStep, setCurrentStep] = useState<'validation' | 'imageUpload'>('validation');
// Refs to hold current values so closures always read fresh state
const sessionIdRef = useRef(sessionId);
const sessionNameRef = useRef(sessionName);
const isDirtyRef = useRef(isDirty);
const isSavingRef = useRef(isSaving);
const userIdRef = useRef(user?.id);
// Keep refs in sync with state
useEffect(() => { sessionIdRef.current = sessionId; }, [sessionId]);
useEffect(() => { sessionNameRef.current = sessionName; }, [sessionName]);
useEffect(() => { isDirtyRef.current = isDirty; }, [isDirty]);
useEffect(() => { isSavingRef.current = isSaving; }, [isSaving]);
useEffect(() => { userIdRef.current = user?.id; }, [user?.id]);
// Ref to hold the data getter function (set by ValidationStep/ImageUploadStep)
const dataGetterRef = useRef<(() => ImportSessionData) | null>(null);
// Ref to hold global selections for inclusion in autosave
const globalSelectionsRef = useRef<import('@/components/product-import/steps/MatchColumnsStep/types').GlobalSelections | undefined>(undefined);
// Autosave timer ref
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clear autosave timer on unmount
useEffect(() => {
return () => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
}
};
}, []);
/**
* Set the data getter function (called by child components)
*/
const setDataGetter = useCallback((getter: () => ImportSessionData) => {
dataGetterRef.current = getter;
}, []);
const setGlobalSelections = useCallback((selections: import('@/components/product-import/steps/MatchColumnsStep/types').GlobalSelections | undefined) => {
globalSelectionsRef.current = selections;
}, []);
const getGlobalSelections = useCallback(() => {
return globalSelectionsRef.current;
}, []);
/**
* Perform save operation. Reads all values from refs to avoid stale closures.
*/
const performSave = useCallback(async (data: ImportSessionData, force = false) => {
const userId = userIdRef.current;
if (!userId || (!force && isSavingRef.current)) return;
setIsSaving(true);
isSavingRef.current = true;
try {
const id = sessionIdRef.current;
if (id) {
// Update existing session (named or unnamed)
await updateSession(id, data);
} else {
// Create/update unnamed autosave session
const result = await autosaveSession({
user_id: userId,
...data,
});
// Store the session ID for future updates
setSessionId(result.id);
sessionIdRef.current = result.id;
}
setLastSaved(new Date());
setIsDirty(false);
isDirtyRef.current = false;
} catch (error) {
console.error('Autosave failed:', error);
// Don't clear dirty flag - will retry on next change
} finally {
setIsSaving(false);
isSavingRef.current = false;
}
}, []);
/**
* Mark the session as having unsaved changes.
* Schedules a debounced autosave.
*/
const markDirty = useCallback(() => {
setIsDirty(true);
isDirtyRef.current = true;
// Schedule autosave
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
}
autosaveTimerRef.current = setTimeout(() => {
if (dataGetterRef.current && userIdRef.current) {
const data = dataGetterRef.current();
performSave(data);
}
}, AUTOSAVE_DEBOUNCE_MS);
}, [performSave]);
/**
* Save current state (autosave or update named session)
*/
const save = useCallback(async () => {
if (!dataGetterRef.current || !userIdRef.current) return;
const data = dataGetterRef.current();
await performSave(data);
}, [performSave]);
/**
* Force immediate save, bypassing debounce (used before closing).
* Always saves if there's data, regardless of dirty state — ensures
* at least one save happens even if no edits were made.
*/
const forceSave = useCallback(async () => {
// Clear any pending autosave timer
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
if (!dataGetterRef.current || !userIdRef.current) return;
const data = dataGetterRef.current();
await performSave(data, true);
}, [performSave]);
/**
* Get a suggested session name based on data (Company - Line format)
*/
const getSuggestedSessionName = useCallback((): string | null => {
if (!dataGetterRef.current) return null;
try {
const data = dataGetterRef.current();
if (!data.data || data.data.length === 0) return null;
// Build lookup helpers from the validation store
const storeState = useValidationStore.getState();
const companyField = storeState.fields.find((f) => f.key === 'company');
const companyOptions = companyField?.fieldType && 'options' in companyField.fieldType
? (companyField.fieldType.options as { label: string; value: string }[])
: [];
const resolveCompany = (id: string) =>
companyOptions.find((o) => String(o.value) === id)?.label ?? id;
const resolveLine = (companyId: string, lineId: string) => {
const cached = storeState.productLinesCache?.get(String(companyId));
return cached?.find((o) => String(o.value) === lineId)?.label ?? lineId;
};
// Find the first row with both company and line
for (const row of data.data) {
const company = row.company;
const line = row.line;
if (company && line) {
const companyStr = String(company).trim();
const lineStr = String(line).trim();
if (companyStr && lineStr) {
return `${resolveCompany(companyStr)} - ${resolveLine(companyStr, lineStr)}`;
}
}
}
// If no row has both, try company alone
for (const row of data.data) {
const company = row.company;
if (company) {
const companyStr = String(company).trim();
if (companyStr) {
return resolveCompany(companyStr);
}
}
}
return null;
} catch {
return null;
}
}, []);
/**
* Save current state as a named session.
* If already tracking a named session, updates it. Otherwise creates a new one.
*/
const saveAsNamed = useCallback(async (name: string): Promise<ImportSession> => {
if (!dataGetterRef.current || !userIdRef.current) {
throw new Error('Cannot save: no data or user');
}
setIsSaving(true);
isSavingRef.current = true;
try {
const data = dataGetterRef.current();
const existingId = sessionIdRef.current;
let result: ImportSession;
if (existingId) {
// Existing session (named or unnamed autosave) - update it with the name
result = await updateSession(existingId, { ...data, name });
} else {
// No session exists yet - create a new named session
result = await createSession({
user_id: userIdRef.current,
name,
...data,
});
}
// Update context to track this named session
setSessionId(result.id);
sessionIdRef.current = result.id;
setSessionName(name);
sessionNameRef.current = name;
setLastSaved(new Date());
setIsDirty(false);
isDirtyRef.current = false;
return result;
} finally {
setIsSaving(false);
isSavingRef.current = false;
}
}, []);
/**
* Load a session (called when restoring from saved sessions list)
*/
const loadSession = useCallback((session: ImportSession) => {
setSessionId(session.id);
sessionIdRef.current = session.id;
setSessionName(session.name);
sessionNameRef.current = session.name;
setCurrentStep(session.current_step);
setLastSaved(new Date(session.updated_at));
setIsDirty(false);
isDirtyRef.current = false;
}, []);
/**
* Clear current session state (for starting fresh)
*/
const clearSession = useCallback(() => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
}
setSessionId(null);
sessionIdRef.current = null;
setSessionName(null);
sessionNameRef.current = null;
setIsDirty(false);
isDirtyRef.current = false;
setLastSaved(null);
setCurrentStep('validation');
dataGetterRef.current = null;
}, []);
/**
* Delete the current session (called on successful submit)
*/
const handleDeleteSession = useCallback(async () => {
const id = sessionIdRef.current;
if (!id) return;
try {
await deleteSession(id);
clearSession();
} catch (error) {
console.error('Failed to delete session:', error);
// Clear local state anyway
clearSession();
}
}, [clearSession]);
return (
<ImportSessionContext.Provider
value={{
sessionId,
sessionName,
isDirty,
lastSaved,
isSaving,
currentStep,
setCurrentStep,
markDirty,
saveAsNamed,
save,
forceSave,
loadSession,
clearSession,
deleteSession: handleDeleteSession,
setDataGetter,
getSuggestedSessionName,
setGlobalSelections,
getGlobalSelections,
}}
>
{children}
</ImportSessionContext.Provider>
);
}
/**
* Hook to use the import session context
*/
export function useImportSession() {
const context = useContext(ImportSessionContext);
if (!context) {
throw new Error('useImportSession must be used within an ImportSessionProvider');
}
return context;
}

View File

@@ -0,0 +1,96 @@
/**
* useImportAutosave Hook
*
* Connects a component to the import session autosave system.
* Registers a data getter and tracks changes to trigger autosaves.
*/
import { useEffect, useCallback, useRef } from 'react';
import { useImportSession } from '@/contexts/ImportSessionContext';
import type { ImportSessionData } from '@/types/importSession';
interface UseImportAutosaveOptions {
/** Whether autosave is enabled for this component */
enabled: boolean;
/** Function that returns the current session data to save */
getSessionData: () => ImportSessionData;
/** Current step identifier */
step: 'validation' | 'imageUpload';
}
/**
* Hook to integrate a component with the import session autosave system.
*
* @example
* ```tsx
* const { markDirty } = useImportAutosave({
* enabled: true,
* step: 'validation',
* getSessionData: () => ({
* current_step: 'validation',
* data: rows,
* validation_state: { errors, upcStatus, generatedItemNumbers },
* }),
* });
*
* // Call markDirty whenever data changes
* const handleCellChange = (value) => {
* updateCell(value);
* markDirty();
* };
* ```
*/
export function useImportAutosave({
enabled,
getSessionData,
step,
}: UseImportAutosaveOptions) {
const {
setDataGetter,
setCurrentStep,
markDirty: contextMarkDirty,
isSaving,
lastSaved,
sessionId,
sessionName,
} = useImportSession();
// Keep getSessionData ref updated to avoid stale closures
const getSessionDataRef = useRef(getSessionData);
useEffect(() => {
getSessionDataRef.current = getSessionData;
}, [getSessionData]);
// Register the data getter with the context and trigger initial autosave
useEffect(() => {
if (enabled) {
setDataGetter(() => getSessionDataRef.current());
setCurrentStep(step);
// Trigger initial autosave so the session is persisted immediately
contextMarkDirty();
}
}, [enabled, setDataGetter, setCurrentStep, step, contextMarkDirty]);
/**
* Mark the session as dirty (has unsaved changes).
* This triggers the debounced autosave in the context.
*/
const markDirty = useCallback(() => {
if (enabled) {
contextMarkDirty();
}
}, [enabled, contextMarkDirty]);
return {
/** Call this when data changes to trigger autosave */
markDirty,
/** Whether a save operation is currently in progress */
isSaving,
/** Timestamp of last successful save */
lastSaved,
/** Current session ID (null if new/unsaved) */
sessionId,
/** Current session name (null if unnamed) */
sessionName,
};
}

View File

@@ -18,7 +18,7 @@ import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/ap
import { AuthContext } from "@/contexts/AuthContext"; import { AuthContext } from "@/contexts/AuthContext";
import { TemplateForm } from "@/components/templates/TemplateForm"; import { TemplateForm } from "@/components/templates/TemplateForm";
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>; type NormalizedProduct = Record<ImportFieldKey | "product_images" | "show_new_product", string | string[] | boolean | null>;
type ImportResult = Result<string> & { all?: Result<string>["validData"] }; type ImportResult = Result<string> & { all?: Result<string>["validData"] };
interface BackendProductResult { interface BackendProductResult {
@@ -271,200 +271,6 @@ export function Import() {
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug")); const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// ========== TEMPORARY TEST DATA ==========
// Uncomment the useEffect below to test the results page without submitting actual data
// useEffect(() => {
// // Test scenario: Mix of successful and failed products
// const testSubmittedProducts: NormalizedProduct[] = [
// {
// name: "Test Product 1",
// upc: "123456789012",
// item_number: "ITEM-001",
// company: "Test Company",
// line: "Test Line",
// subline: "Test Subline",
// product_images: ["https://picsum.photos/200/200?random=1"],
// short_description: "This is a test product",
// retail: "29.99",
// wholesale: "15.00",
// weight: "1.5",
// categories: ["Category 1", "Category 2"],
// colors: ["Red", "Blue"],
// size_cat: "Medium",
// tax_cat: "Taxable",
// ship_restrictions: "None",
// supplier: "Test Supplier",
// artist: null,
// themes: ["Theme 1"],
// vendor_sku: "VS-001",
// publish: true,
// list_on_marketplace: false,
// },
// {
// name: "Test Product 2",
// upc: "234567890123",
// item_number: "ITEM-002",
// company: "Test Company",
// line: "Test Line",
// subline: "Test Subline",
// product_images: ["https://picsum.photos/200/200?random=2"],
// short_description: "Another test product",
// retail: "49.99",
// wholesale: "25.00",
// weight: "2.0",
// categories: ["Category 3"],
// colors: ["Green"],
// size_cat: "Large",
// tax_cat: "Taxable",
// ship_restrictions: "None",
// supplier: "Test Supplier",
// artist: "Test Artist",
// themes: [],
// vendor_sku: "VS-002",
// publish: true,
// list_on_marketplace: true,
// },
// {
// name: "Failed Product 1",
// upc: "345678901234",
// item_number: "ITEM-003",
// company: "Test Company",
// line: "Test Line",
// subline: null,
// product_images: ["https://picsum.photos/200/200?random=3"],
// short_description: "This product will fail",
// retail: "19.99",
// wholesale: "10.00",
// weight: "0.5",
// categories: [],
// colors: [],
// size_cat: null,
// tax_cat: "Taxable",
// ship_restrictions: null,
// supplier: null,
// artist: null,
// themes: [],
// vendor_sku: "VS-003",
// publish: false,
// list_on_marketplace: false,
// },
// {
// name: "Failed Product 2",
// upc: "456789012345",
// item_number: "ITEM-004",
// company: "Test Company",
// line: null,
// subline: null,
// product_images: null,
// description: "Another failed product",
// msrp: "99.99",
// cost_each: "50.00",
// weight: "5.0",
// categories: ["Category 1"],
// colors: ["Yellow"],
// size_cat: "Small",
// tax_cat: null,
// ship_restrictions: "Hazmat",
// supplier: "Test Supplier",
// artist: null,
// themes: [],
// vendor_sku: null,
// publish: true,
// list_on_marketplace: false,
// },
// ];
// const testSubmittedRows: Data<string>[] = testSubmittedProducts.map(product => ({ ...product } as Data<string>));
// //Scenario 1: All successful
// const testResponse: SubmitNewProductsResponse = {
// success: true,
// message: "Successfully created 4 products",
// data: {
// created: [
// { pid: 12345, upc: "123456789012", item_number: "ITEM-001" },
// { pid: 12346, upc: "234567890123", item_number: "ITEM-002" },
// { pid: 12347, upc: "345678901234", item_number: "ITEM-003" },
// { pid: 12348, upc: "456789012345", item_number: "ITEM-004" },
// ],
// errored: [],
// },
// };
// // Scenario 2: Partial success (2 created, 2 failed)
// const testResponse: SubmitNewProductsResponse = {
// success: true,
// message: "Created 2 of 4 products. 2 products had errors.",
// data: {
// created: [
// { pid: 12345, upc: "123456789012", item_number: "ITEM-001" },
// { pid: 12346, upc: "234567890123", item_number: "ITEM-002" },
// ],
// errored: [
// {
// upc: "345678901234",
// item_number: "ITEM-003",
// error_msg: "Missing required field: supplier",
// errors: {
// supplier: ["Supplier is required for this product line"],
// categories: ["At least one category must be selected"],
// },
// },
// {
// upc: "456789012345",
// item_number: "ITEM-004",
// error_msg: "Invalid product configuration",
// errors: {
// line: ["Product line is required"],
// tax_cat: ["Tax category must be specified"],
// },
// },
// ],
// query_id: "1234567890",
// },
// };
// // Scenario 3: Complete failure
// const testResponse: SubmitNewProductsResponse = {
// success: false,
// message: "Failed to create products. Please check the errors below.",
// data: {
// created: [],
// errored: [
// {
// upc: "123456789012",
// item_number: "ITEM-001",
// error_msg: "A product with this UPC already exists",
// },
// {
// upc: "234567890123",
// item_number: "ITEM-002",
// error_msg: "Invalid wholesale price",
// },
// {
// upc: "345678901234",
// item_number: "ITEM-003",
// error_msg: "Missing required field: supplier",
// },
// {
// upc: "456789012345",
// item_number: "ITEM-004",
// error_msg: "Invalid product configuration",
// },
// ],
// },
// };
// setImportOutcome({
// submittedProducts: testSubmittedProducts,
// submittedRows: testSubmittedRows,
// response: testResponse,
// });
// }, []);
// ========== END TEST DATA ==========
// Fetch initial field options from the API // Fetch initial field options from the API
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({ const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
queryKey: ["import-field-options"], queryKey: ["import-field-options"],
@@ -734,9 +540,13 @@ export function Import() {
normalizedProductImages = rawProductImages; normalizedProductImages = rawProductImages;
} }
// Preserve show_new_product flag if it was set
const showNewProduct = (row as Record<string, unknown>).show_new_product;
return { return {
...baseValues, ...baseValues,
product_images: normalizedProductImages, product_images: normalizedProductImages,
...(showNewProduct === true && { show_new_product: true }),
} as NormalizedProduct; } as NormalizedProduct;
}); });

View File

@@ -0,0 +1,55 @@
import { useState, useEffect } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { ProductSearch } from "@/components/product-editor/ProductSearch";
import { ProductEditForm } from "@/components/product-editor/ProductEditForm";
import type { SearchProduct, FieldOptions } from "@/components/product-editor/types";
export default function ProductEditor() {
const [selectedProduct, setSelectedProduct] = useState<SearchProduct | null>(null);
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(null);
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
useEffect(() => {
axios
.get("/api/import/field-options")
.then((res) => setFieldOptions(res.data))
.catch((err) => {
console.error("Failed to load field options:", err);
toast.error("Failed to load field options");
})
.finally(() => setIsLoadingOptions(false));
}, []);
if (isLoadingOptions) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="container mx-auto py-6 max-w-4xl space-y-6">
<div>
<h1 className="text-2xl font-bold">Product Editor</h1>
<p className="text-muted-foreground">
Search for a product and edit its fields. Only changed fields will be
submitted.
</p>
</div>
<ProductSearch onSelect={setSelectedProduct} />
{selectedProduct && fieldOptions && (
<ProductEditForm
key={selectedProduct.pid}
product={selectedProduct}
fieldOptions={fieldOptions}
onClose={() => setSelectedProduct(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,139 @@
/**
* Import Session API Service
*
* Handles all API calls for import session persistence.
*/
import type {
ImportSession,
ImportSessionListItem,
ImportSessionCreateRequest,
ImportSessionUpdateRequest,
ImportSessionAutosaveRequest,
} from '@/types/importSession';
const BASE_URL = '/api/import-sessions';
/**
* Helper to handle API responses
*/
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorBody = await response.text();
let errorMessage: string;
try {
const parsed = JSON.parse(errorBody);
errorMessage = parsed.error || parsed.message || `HTTP ${response.status}`;
} catch {
errorMessage = errorBody || `HTTP ${response.status}`;
}
throw new Error(errorMessage);
}
return response.json();
}
/**
* List all sessions for a user (named + unnamed)
*/
export async function listSessions(userId: number): Promise<ImportSessionListItem[]> {
const response = await fetch(`${BASE_URL}?user_id=${userId}`);
return handleResponse<ImportSessionListItem[]>(response);
}
/**
* Get a specific session by ID (includes full data)
*/
export async function getSession(id: number): Promise<ImportSession> {
const response = await fetch(`${BASE_URL}/${id}`);
return handleResponse<ImportSession>(response);
}
/**
* Create a new named session
*/
export async function createSession(data: ImportSessionCreateRequest): Promise<ImportSession> {
const response = await fetch(BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return handleResponse<ImportSession>(response);
}
/**
* Update an existing session by ID
*/
export async function updateSession(id: number, data: ImportSessionUpdateRequest): Promise<ImportSession> {
const response = await fetch(`${BASE_URL}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return handleResponse<ImportSession>(response);
}
/**
* Autosave - upsert the unnamed session for a user
*/
export async function autosaveSession(data: ImportSessionAutosaveRequest): Promise<ImportSession> {
const response = await fetch(`${BASE_URL}/autosave`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return handleResponse<ImportSession>(response);
}
/**
* Delete a session by ID
*/
export async function deleteSession(id: number): Promise<void> {
const response = await fetch(`${BASE_URL}/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorBody = await response.text();
let errorMessage: string;
try {
const parsed = JSON.parse(errorBody);
errorMessage = parsed.error || parsed.message || `HTTP ${response.status}`;
} catch {
errorMessage = errorBody || `HTTP ${response.status}`;
}
throw new Error(errorMessage);
}
}
/**
* Delete the unnamed/autosave session for a user
*/
export async function deleteAutosaveSession(userId: number): Promise<void> {
const response = await fetch(`${BASE_URL}/autosave/${userId}`, {
method: 'DELETE',
});
// 404 is ok - means no autosave existed
if (!response.ok && response.status !== 404) {
const errorBody = await response.text();
let errorMessage: string;
try {
const parsed = JSON.parse(errorBody);
errorMessage = parsed.error || parsed.message || `HTTP ${response.status}`;
} catch {
errorMessage = errorBody || `HTTP ${response.status}`;
}
throw new Error(errorMessage);
}
}
/**
* Convenience export for all API methods
*/
export const importSessionApi = {
list: listSessions,
get: getSession,
create: createSession,
update: updateSession,
autosave: autosaveSession,
delete: deleteSession,
deleteAutosave: deleteAutosaveSession,
};

View File

@@ -0,0 +1,98 @@
export interface ImageChanges {
order: (number | string)[];
hidden: number[];
deleted: number[];
added: Record<string, string>; // e.g. { "new-0": "https://..." }
}
export interface SubmitProductEditArgs {
pid: number;
changes: Record<string, unknown>;
environment: "dev" | "prod";
imageChanges?: ImageChanges;
}
export interface SubmitProductEditResponse {
success: boolean;
message?: string;
data?: unknown;
error?: unknown;
}
const DEV_ENDPOINT = "/apiv2-test/product/edit";
const PROD_ENDPOINT = "/apiv2/product/edit";
const isHtmlResponse = (payload: string) => {
const trimmed = payload.trim();
return trimmed.startsWith("<!DOCTYPE html") || trimmed.startsWith("<html");
};
export async function submitProductEdit({
pid,
changes,
environment,
imageChanges,
}: SubmitProductEditArgs): Promise<SubmitProductEditResponse> {
const targetUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
const product: Record<string, unknown> = { pid, ...changes };
if (imageChanges) {
product.image_changes = imageChanges;
}
const payload = new URLSearchParams();
payload.append("products", JSON.stringify([product]));
const fetchOptions: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: payload,
};
if (environment === "dev") {
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
if (authToken) {
payload.append("auth", authToken);
fetchOptions.body = payload;
}
} else {
fetchOptions.credentials = "include";
}
let response: Response;
try {
response = await fetch(targetUrl, fetchOptions);
} catch (networkError) {
throw new Error(
networkError instanceof Error ? networkError.message : "Network request failed"
);
}
const rawBody = await response.text();
if (isHtmlResponse(rawBody)) {
throw new Error(
"Backend authentication required. Please ensure you are logged into the backend system."
);
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
throw new Error(`Unexpected response from backend (${response.status}).`);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Empty response from backend");
}
const parsedResponse = parsed as Record<string, unknown>;
return {
success: Boolean(parsedResponse.success),
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
data: parsedResponse.data,
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
};
}

View File

@@ -0,0 +1,158 @@
/**
* Import Session Types
*
* Types for managing product import session persistence,
* including autosave and named session management.
*/
import type { RowData, ValidationError, UpcValidationStatus } from '@/components/product-import/steps/ValidationStep/store/types';
import type { ProductImageSortable } from '@/components/product-import/steps/ImageUploadStep/types';
import type { GlobalSelections } from '@/components/product-import/steps/MatchColumnsStep/types';
// =============================================================================
// Session Data Types
// =============================================================================
/**
* Serialized validation state for database storage.
* Maps are converted to plain objects for JSONB storage.
*/
export interface SerializedValidationState {
/** Errors by row index, then by field key */
errors: Record<number, Record<string, ValidationError[]>>;
/** UPC validation status by row index */
upcStatus: Record<number, UpcValidationStatus>;
/** Generated item numbers by row index */
generatedItemNumbers: Record<number, string>;
}
/**
* Session data payload for API requests (create/update)
*/
export interface ImportSessionData {
current_step: 'validation' | 'imageUpload';
data: RowData[];
product_images?: ProductImageSortable[];
global_selections?: GlobalSelections;
validation_state?: SerializedValidationState;
}
/**
* Full session record from the database
*/
export interface ImportSession {
id: number;
user_id: number;
name: string | null;
current_step: 'validation' | 'imageUpload';
data: RowData[];
product_images: ProductImageSortable[] | null;
global_selections: GlobalSelections | null;
validation_state: SerializedValidationState | null;
created_at: string;
updated_at: string;
}
/**
* Session list item (lighter version without full data)
*/
export interface ImportSessionListItem {
id: number;
user_id: number;
name: string | null;
current_step: 'validation' | 'imageUpload';
row_count: number;
global_selections: GlobalSelections | null;
created_at: string;
updated_at: string;
}
// =============================================================================
// Context Types
// =============================================================================
/**
* Import session context state and actions
*/
export interface ImportSessionContextValue {
/** Current session ID (null = new/unnamed session) */
sessionId: number | null;
/** Session name (null = unnamed autosave) */
sessionName: string | null;
/** Whether there are unsaved changes */
isDirty: boolean;
/** Timestamp of last successful save */
lastSaved: Date | null;
/** Whether a save operation is in progress */
isSaving: boolean;
/** Current step for session tracking */
currentStep: 'validation' | 'imageUpload';
/** Set the current step */
setCurrentStep: (step: 'validation' | 'imageUpload') => void;
/** Mark session as dirty (has unsaved changes) */
markDirty: () => void;
/** Save current state as a named session */
saveAsNamed: (name: string) => Promise<ImportSession>;
/** Update existing named session or create autosave */
save: () => Promise<void>;
/** Force immediate save (ignoring debounce), used before closing */
forceSave: () => Promise<void>;
/** Load a session by ID */
loadSession: (session: ImportSession) => void;
/** Clear current session state */
clearSession: () => void;
/** Delete session from server (called on submit) */
deleteSession: () => Promise<void>;
/** Set the data getter function for autosave */
setDataGetter: (getter: () => ImportSessionData) => void;
/** Get a suggested session name based on data (Company - Line) */
getSuggestedSessionName: () => string | null;
/** Store global selections for inclusion in autosave data */
setGlobalSelections: (selections: GlobalSelections | undefined) => void;
/** Get stored global selections */
getGlobalSelections: () => GlobalSelections | undefined;
}
// =============================================================================
// Hook Types
// =============================================================================
export interface UseImportAutosaveOptions {
/** Whether autosave is enabled */
enabled: boolean;
/** Debounce delay in milliseconds (default: 10000) */
debounceMs?: number;
}
// =============================================================================
// API Response Types
// =============================================================================
export interface ImportSessionCreateRequest {
user_id: number;
name: string;
current_step: 'validation' | 'imageUpload';
data: RowData[];
product_images?: ProductImageSortable[];
global_selections?: GlobalSelections;
validation_state?: SerializedValidationState;
}
export interface ImportSessionUpdateRequest {
name?: string;
current_step: 'validation' | 'imageUpload';
data: RowData[];
product_images?: ProductImageSortable[];
global_selections?: GlobalSelections;
validation_state?: SerializedValidationState;
}
export interface ImportSessionAutosaveRequest {
user_id: number;
current_step: 'validation' | 'imageUpload';
data: RowData[];
product_images?: ProductImageSortable[];
global_selections?: GlobalSelections;
validation_state?: SerializedValidationState;
}

File diff suppressed because one or more lines are too long

View File

@@ -90,31 +90,31 @@ export default defineConfig(({ mode }) => {
cookieDomainRewrite: "localhost", cookieDomainRewrite: "localhost",
}, },
"/api/aircall": { "/api/aircall": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path, rewrite: (path) => path,
}, },
"/api/klaviyo": { "/api/klaviyo": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path, rewrite: (path) => path,
}, },
"/api/meta": { "/api/meta": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path, rewrite: (path) => path,
}, },
"/api/gorgias": { "/api/gorgias": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path, rewrite: (path) => path,
}, },
"/api/dashboard-analytics": { "/api/dashboard-analytics": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
cookieDomainRewrite: { cookieDomainRewrite: {
@@ -122,25 +122,25 @@ export default defineConfig(({ mode }) => {
}, },
}, },
"/api/typeform": { "/api/typeform": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path, rewrite: (path) => path,
}, },
"/api/acot": { "/api/acot": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path, rewrite: (path) => path,
}, },
"/api/clarity": { "/api/clarity": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path, rewrite: (path) => path,
}, },
"/api": { "/api": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
ws: true, ws: true,
@@ -161,14 +161,14 @@ export default defineConfig(({ mode }) => {
}, },
}, },
"/dashboard-auth": { "/dashboard-auth": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
ws: true, ws: true,
rewrite: (path) => path.replace("/dashboard-auth", "/auth"), rewrite: (path) => path.replace("/dashboard-auth", "/auth"),
}, },
"/auth-inv": { "/auth-inv": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
ws: true, ws: true,
@@ -195,7 +195,7 @@ export default defineConfig(({ mode }) => {
}, },
}, },
"/chat-api": { "/chat-api": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
ws: true, ws: true,
@@ -216,7 +216,7 @@ export default defineConfig(({ mode }) => {
}, },
}, },
"/uploads": { "/uploads": {
target: "https://acot.site", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path, rewrite: (path) => path,