Compare commits
4 Commits
ec8ab17d3f
...
01d4097030
| Author | SHA1 | Date | |
|---|---|---|---|
| 01d4097030 | |||
| f9e8c9265e | |||
| ee2f314775 | |||
| 11d0555eeb |
2
CLAUDE.md
Normal file
2
CLAUDE.md
Normal 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
|
||||
29
inventory-server/migrations/001_create_import_sessions.sql
Normal file
29
inventory-server/migrations/001_create_import_sessions.sql
Normal 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';
|
||||
337
inventory-server/src/routes/import-sessions.js
Normal file
337
inventory-server/src/routes/import-sessions.js
Normal 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;
|
||||
@@ -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_MAX_SEQUENCE = 99999;
|
||||
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@@ -23,6 +23,7 @@ const categoriesAggregateRouter = require('./routes/categoriesAggregate');
|
||||
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
||||
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
||||
const htsLookupRouter = require('./routes/hts-lookup');
|
||||
const importSessionsRouter = require('./routes/import-sessions');
|
||||
|
||||
// Get the absolute path to the .env file
|
||||
const envPath = '/var/www/html/inventory/.env';
|
||||
@@ -130,6 +131,7 @@ async function startServer() {
|
||||
app.use('/api/ai-prompts', aiPromptsRouter);
|
||||
app.use('/api/reusable-images', reusableImagesRouter);
|
||||
app.use('/api/hts-lookup', htsLookupRouter);
|
||||
app.use('/api/import-sessions', importSessionsRouter);
|
||||
|
||||
// Basic health check route
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
198
inventory/package-lock.json
generated
198
inventory/package-lock.json
generated
@@ -28,7 +28,7 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@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-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
@@ -51,6 +51,7 @@
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^7.0.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.4.4",
|
||||
"immer": "^11.1.3",
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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": {
|
||||
"version": "1.1.6",
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"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": {
|
||||
"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==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@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": {
|
||||
"version": "1.1.3",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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,
|
||||
"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": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@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-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
@@ -55,6 +55,7 @@
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^7.0.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.4.4",
|
||||
"immer": "^11.1.3",
|
||||
"input-otp": "^1.4.1",
|
||||
|
||||
@@ -36,6 +36,9 @@ const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
|
||||
// 3. Product import - separate chunk
|
||||
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
|
||||
|
||||
// Product editor
|
||||
const ProductEditor = lazy(() => import('./pages/ProductEditor'));
|
||||
|
||||
// 4. Chat archive - separate chunk
|
||||
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
|
||||
|
||||
@@ -185,6 +188,15 @@ function App() {
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Product editor */}
|
||||
<Route path="/product-editor" element={
|
||||
<Protected page="product_editor">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<ProductEditor />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Product import - separate chunk */}
|
||||
<Route path="/import" element={
|
||||
<Protected page="import">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Percent,
|
||||
FileSearch,
|
||||
ShoppingCart,
|
||||
FilePenLine,
|
||||
} from "lucide-react";
|
||||
import { IconCrystalBall } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -113,6 +114,12 @@ const toolsItems = [
|
||||
icon: IconCrystalBall,
|
||||
url: "/forecasting",
|
||||
permission: "access:forecasting"
|
||||
},
|
||||
{
|
||||
title: "Product Editor",
|
||||
icon: FilePenLine,
|
||||
url: "/product-editor",
|
||||
permission: "access:product_editor"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
85
inventory/src/components/product-editor/ComboboxField.tsx
Normal file
85
inventory/src/components/product-editor/ComboboxField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
403
inventory/src/components/product-editor/ImageManager.tsx
Normal file
403
inventory/src/components/product-editor/ImageManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
528
inventory/src/components/product-editor/ProductEditForm.tsx
Normal file
528
inventory/src/components/product-editor/ProductEditForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
inventory/src/components/product-editor/ProductSearch.tsx
Normal file
114
inventory/src/components/product-editor/ProductSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
inventory/src/components/product-editor/types.ts
Normal file
95
inventory/src/components/product-editor/types.ts
Normal 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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Providers } from "./components/Providers"
|
||||
import type { RsiProps } from "./types"
|
||||
import { ModalWrapper } from "./components/ModalWrapper"
|
||||
import { translations } from "./translationsRSIProps"
|
||||
import { ImportSessionProvider } from "@/contexts/ImportSessionContext"
|
||||
|
||||
// Simple empty theme placeholder
|
||||
export const defaultTheme = {}
|
||||
@@ -29,10 +30,12 @@ export const ReactSpreadsheetImport = <T extends string>(propsWithoutDefaults: R
|
||||
props.translations !== translations ? merge(translations, props.translations) : translations
|
||||
|
||||
return (
|
||||
<ImportSessionProvider>
|
||||
<Providers rsiValues={{ ...props, translations: mergedTranslations }}>
|
||||
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
|
||||
<Steps />
|
||||
</ModalWrapper>
|
||||
</Providers>
|
||||
</ImportSessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,12 @@
|
||||
import type React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { useRsi } from "../hooks/useRsi"
|
||||
import { useState, useCallback } from "react"
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { CloseConfirmationDialog } from "./CloseConfirmationDialog"
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
@@ -29,76 +15,85 @@ type Props = {
|
||||
}
|
||||
|
||||
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
|
||||
const { rtl, translations } = useRsi()
|
||||
const { rtl } = useRsi()
|
||||
const [showCloseAlert, setShowCloseAlert] = useState(false)
|
||||
// Guard: when we're programmatically closing, don't re-show the alert
|
||||
const closingRef = useRef(false)
|
||||
|
||||
// Create a handler that resets scroll positions before closing
|
||||
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');
|
||||
scrollContainers.forEach(container => {
|
||||
if (container instanceof HTMLElement) {
|
||||
// Reset scroll position to top-left
|
||||
container.scrollTop = 0;
|
||||
container.scrollLeft = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Call the original onClose handler
|
||||
// Close the main dialog
|
||||
onClose();
|
||||
|
||||
// Reset the guard after a tick (after Radix fires onOpenChange)
|
||||
requestAnimationFrame(() => {
|
||||
closingRef.current = false
|
||||
})
|
||||
}, [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 (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={() => setShowCloseAlert(true)} modal>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-background/80 backdrop-blur-sm" />
|
||||
<DialogContent
|
||||
{/*
|
||||
NOTE: We use DialogPrimitive.Portal/Overlay/Content directly instead of the
|
||||
shadcn DialogContent component. The shadcn DialogContent internally renders its
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
setShowCloseAlert(true)
|
||||
}}
|
||||
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>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DialogClose className="absolute right-4 top-4" onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setShowCloseAlert(true)
|
||||
}} />
|
||||
</AlertDialogTrigger>
|
||||
</AlertDialog>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => setShowCloseAlert(true)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
<div dir={rtl ? "rtl" : "ltr"} className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showCloseAlert} onOpenChange={setShowCloseAlert}>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay className="z-[1400]" />
|
||||
<AlertDialogContent className="z-[1500]">
|
||||
<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>
|
||||
<CloseConfirmationDialog
|
||||
open={showCloseAlert}
|
||||
onOpenChange={setShowCloseAlert}
|
||||
onConfirmClose={handleConfirmClose}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -24,8 +24,22 @@ import { useBulkImageUpload } from "./hooks/useBulkImageUpload";
|
||||
import { useUrlImageUpload } from "./hooks/useUrlImageUpload";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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 { useImportAutosave } from "@/hooks/useImportAutosave";
|
||||
import { useImportSession } from "@/contexts/ImportSessionContext";
|
||||
import { SaveSessionButton } from "../../components/SaveSessionDialog";
|
||||
import type { SubmitOptions } from "../../types";
|
||||
import type { ImportSessionData } from "@/types/importSession";
|
||||
|
||||
interface Props {
|
||||
data: Product[];
|
||||
@@ -48,6 +62,11 @@ export const ImageUploadStep = ({
|
||||
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
|
||||
const [useTestDataSource, setUseTestDataSource] = 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
|
||||
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
|
||||
@@ -90,6 +109,31 @@ export const ImageUploadStep = ({
|
||||
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
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -171,7 +215,9 @@ export const ImageUploadStep = ({
|
||||
return {
|
||||
...product,
|
||||
// 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 })
|
||||
};
|
||||
});
|
||||
|
||||
@@ -179,26 +225,49 @@ export const ImageUploadStep = ({
|
||||
targetEnvironment,
|
||||
useTestDataSource,
|
||||
skipApiSubmission,
|
||||
showNewProduct,
|
||||
};
|
||||
|
||||
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) {
|
||||
console.error('Submit error:', error);
|
||||
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission]);
|
||||
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission, showNewProduct, deleteImportSession]);
|
||||
|
||||
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 */}
|
||||
<div className="px-8 py-6 bg-background shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Add Product Images</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Drag images to reorder them or move them between products.
|
||||
</p>
|
||||
</div>
|
||||
<SaveSessionButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area - only this part scrolls */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
@@ -297,6 +366,24 @@ export const ImageUploadStep = ({
|
||||
</Button>
|
||||
)}
|
||||
<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 && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
{!skipApiSubmission && (
|
||||
@@ -351,6 +438,30 @@ export const ImageUploadStep = ({
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ const getFullImageUrl = (url: string): string => {
|
||||
}
|
||||
|
||||
// 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
|
||||
const path = url.startsWith('/') ? url : `/${url}`;
|
||||
return `${baseUrl}${path}`;
|
||||
|
||||
@@ -74,7 +74,7 @@ export const useProductImagesInit = (data: Product[]) => {
|
||||
}
|
||||
|
||||
// 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
|
||||
const path = url.startsWith('/') ? url : `/${url}`;
|
||||
return `${baseUrl}${path}`;
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
import { StepState, StepType, UploadFlow } from "./UploadFlow"
|
||||
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 { CgCheck } from "react-icons/cg"
|
||||
import { ImportSessionContext } from "@/contexts/ImportSessionContext"
|
||||
|
||||
const CheckIcon = ({ color }: { color: string }) => <CgCheck size="24" className={color} />
|
||||
|
||||
export const Steps = () => {
|
||||
const { initialStepState, translations, isNavigationEnabled, isOpen } = useRsi()
|
||||
const { initialStepState, translations, isNavigationEnabled } = useRsi()
|
||||
const { clearSession } = useContext(ImportSessionContext)
|
||||
const initialStep = stepTypeToStepIndex(initialStepState?.type)
|
||||
const [activeStep, setActiveStep] = useState(initialStep)
|
||||
const [state, setState] = useState<StepState>(initialStepState || { type: StepType.upload })
|
||||
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(() => {
|
||||
// Check if dialog was closed and is now open again
|
||||
if (isOpen && !prevIsOpen.current) {
|
||||
// Reset to initial state
|
||||
setActiveStep(initialStep)
|
||||
setState(initialStepState || { type: StepType.upload })
|
||||
history.current = []
|
||||
}
|
||||
|
||||
// Update previous isOpen value
|
||||
prevIsOpen.current = isOpen
|
||||
}, [isOpen, initialStep, initialStepState])
|
||||
clearSession()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onClickStep = (stepIndex: number) => {
|
||||
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
|
||||
const nextHistory = history.current.slice(0, historyIdx + 1)
|
||||
history.current = nextHistory
|
||||
@@ -39,7 +40,14 @@ export const Steps = () => {
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -51,7 +59,10 @@ export const Steps = () => {
|
||||
const validationStepIndex = steps.indexOf('validationStep')
|
||||
setActiveStep(validationStepIndex)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Progress } from "@/components/ui/progress"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
||||
import { useValidationStore } from "./ValidationStep/store/validationStore"
|
||||
import { useImportSession } from "@/contexts/ImportSessionContext"
|
||||
import type { ImportSession } from "@/types/importSession"
|
||||
|
||||
export enum StepType {
|
||||
upload = "upload",
|
||||
@@ -127,6 +129,42 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
: 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) {
|
||||
case StepType.upload:
|
||||
@@ -164,6 +202,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
});
|
||||
}
|
||||
}}
|
||||
onRestoreSession={handleRestoreSession}
|
||||
/>
|
||||
)
|
||||
case StepType.selectSheet:
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import type XLSX from "xlsx"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback, useState, useContext } from "react"
|
||||
import { useRsi } from "../../hooks/useRsi"
|
||||
import { DropZone } from "./components/DropZone"
|
||||
import { StepType } from "../UploadFlow"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 = {
|
||||
onContinue: (data: XLSX.WorkBook, file: File) => Promise<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 { 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(
|
||||
async (data: XLSX.WorkBook, file: File) => {
|
||||
@@ -30,10 +53,62 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
||||
}
|
||||
}, [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 (
|
||||
<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>
|
||||
{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="rounded-lg p-6 flex flex-col items-center">
|
||||
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
|
||||
@@ -45,7 +120,7 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
||||
<Separator className="w-24" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="flex justify-center pb-8">
|
||||
<Button
|
||||
onClick={handleStartFromScratch}
|
||||
variant="outline"
|
||||
@@ -55,7 +130,56 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
||||
Start from scratch
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Saved sessions list */}
|
||||
{onRestoreSession && (
|
||||
<SavedSessionsList onRestore={onRestoreSession} />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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 {
|
||||
useTotalErrorCount,
|
||||
@@ -29,8 +29,11 @@ import { AiDebugDialog } from '../dialogs/AiDebugDialog';
|
||||
import { SanityCheckDialog } from '../dialogs/SanityCheckDialog';
|
||||
import { TemplateForm } from '@/components/templates/TemplateForm';
|
||||
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
|
||||
import { useImportAutosave } from '@/hooks/useImportAutosave';
|
||||
import { useImportSession } from '@/contexts/ImportSessionContext';
|
||||
import type { CleanRowData, RowData } from '../store/types';
|
||||
import type { ProductForSanityCheck } from '../hooks/useSanityCheck';
|
||||
import type { ImportSessionData, SerializedValidationState } from '@/types/importSession';
|
||||
|
||||
interface ValidationContainerProps {
|
||||
onBack?: () => void;
|
||||
@@ -71,6 +74,50 @@ export const ValidationContainer = ({
|
||||
// Handle UPC validation after copy-down operations on supplier/upc fields
|
||||
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)
|
||||
const initialProductsRef = useRef<RowData[] | null>(null);
|
||||
if (initialProductsRef.current === null) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -27,6 +27,7 @@ import { useValidationStore } from '../store/validationStore';
|
||||
import {
|
||||
useFields,
|
||||
useFilters,
|
||||
useRowCount,
|
||||
} from '../store/selectors';
|
||||
// NOTE: We intentionally do NOT import useValidationActions or useProductLines here!
|
||||
// Those hooks subscribe to global state (rows, errors, caches) which would cause
|
||||
@@ -1210,8 +1211,10 @@ interface VirtualRowProps {
|
||||
columns: ColumnDef<RowData>[];
|
||||
fields: Field<string>[];
|
||||
totalRowCount: number;
|
||||
/** Whether table is scrolled horizontally - used for sticky column shadow */
|
||||
isScrolledHorizontally: boolean;
|
||||
/** Whether the name column sticky behavior is enabled */
|
||||
nameColumnSticky: boolean;
|
||||
/** Direction for sticky name column: 'left', 'right', or null (not sticky) */
|
||||
stickyDirection: 'left' | 'right' | null;
|
||||
}
|
||||
|
||||
const VirtualRow = memo(({
|
||||
@@ -1221,7 +1224,8 @@ const VirtualRow = memo(({
|
||||
columns,
|
||||
fields,
|
||||
totalRowCount,
|
||||
isScrolledHorizontally,
|
||||
nameColumnSticky,
|
||||
stickyDirection,
|
||||
}: VirtualRowProps) => {
|
||||
// Subscribe to row data - this is THE subscription for all cell values in this row
|
||||
const rowData = useValidationStore(
|
||||
@@ -1317,13 +1321,18 @@ const VirtualRow = memo(({
|
||||
<div
|
||||
data-row-index={rowIndex}
|
||||
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',
|
||||
isSelected && 'bg-primary/5'
|
||||
isSelected && 'bg-blue-100 dark:bg-blue-900/40'
|
||||
)}
|
||||
style={{
|
||||
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
|
||||
zIndex: hasVisibleAiSuggestion ? 10 : undefined,
|
||||
}}
|
||||
@@ -1331,7 +1340,7 @@ const VirtualRow = memo(({
|
||||
>
|
||||
{/* Selection checkbox cell */}
|
||||
<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={{
|
||||
width: columns[0]?.size || 40,
|
||||
minWidth: columns[0]?.size || 40,
|
||||
@@ -1346,7 +1355,7 @@ const VirtualRow = memo(({
|
||||
|
||||
{/* Template column */}
|
||||
<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={{
|
||||
width: TEMPLATE_COLUMN_WIDTH,
|
||||
minWidth: TEMPLATE_COLUMN_WIDTH,
|
||||
@@ -1400,33 +1409,50 @@ const VirtualRow = memo(({
|
||||
|
||||
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 (
|
||||
<div
|
||||
key={field.key}
|
||||
data-cell-field={field.key}
|
||||
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
|
||||
// Description handles AI suggestions inside its popover, so no overflow needed
|
||||
isNameColumn ? "overflow-visible" : "overflow-hidden",
|
||||
// Name column is sticky - needs SOLID (opaque) background that matches row state
|
||||
// Uses gradient trick to composite semi-transparent tint onto solid background
|
||||
// Shadow only shows when scrolled horizontally (column is actually overlaying content)
|
||||
isNameColumn && "lg:sticky lg:z-10",
|
||||
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
|
||||
isNameColumn && (
|
||||
hasErrors
|
||||
// Name column sticky behavior - only when enabled and scrolled appropriately
|
||||
shouldBeSticky && "lg:sticky lg:z-10",
|
||||
// Add left border when sticky-right since content scrolls behind from the left
|
||||
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border))]",
|
||||
// Directional drop 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)]",
|
||||
// Solid background when sticky to overlay content
|
||||
// Use explicit [background:] syntax for consistent specificity
|
||||
// Selection (blue) takes priority over errors (red)
|
||||
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))]"
|
||||
: isSelected
|
||||
? "lg:[background:linear-gradient(hsl(var(--primary)/0.05),hsl(var(--primary)/0.05)),hsl(var(--background))]"
|
||||
: "lg:bg-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={{
|
||||
width: columnWidth,
|
||||
minWidth: columnWidth,
|
||||
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
|
||||
@@ -1462,27 +1488,79 @@ VirtualRow.displayName = 'VirtualRow';
|
||||
|
||||
/**
|
||||
* 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 rowCount = useValidationStore((state) => state.rows.length);
|
||||
const selectedCount = useValidationStore((state) => state.selectedRows.size);
|
||||
const filters = useFilters();
|
||||
// Subscribe to row count to recalculate when rows are added/removed
|
||||
const rowCount = useRowCount();
|
||||
|
||||
const allSelected = rowCount > 0 && selectedCount === rowCount;
|
||||
const someSelected = selectedCount > 0 && selectedCount < rowCount;
|
||||
// Compute which rows are visible based on current filters
|
||||
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 { setSelectedRows, rows } = useValidationStore.getState();
|
||||
const { setSelectedRows, selectedRows: currentSelected } = useValidationStore.getState();
|
||||
if (value) {
|
||||
const allIds = new Set(rows.map((row) => row.__index));
|
||||
setSelectedRows(allIds);
|
||||
// Add all visible rows to selection (keep existing selections of non-visible rows)
|
||||
const newSelection = new Set(currentSelected);
|
||||
visibleRowIds.forEach((id) => newSelection.add(id));
|
||||
setSelectedRows(newSelection);
|
||||
} 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 (
|
||||
<Checkbox
|
||||
checked={allSelected || (someSelected && 'indeterminate')}
|
||||
checked={allVisibleSelected || (someVisibleSelected && 'indeterminate')}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
);
|
||||
@@ -1536,20 +1614,19 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
: 'Fill empty cells with MSRP ÷ 2';
|
||||
|
||||
// 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 { rows } = useValidationStore.getState();
|
||||
return rows.some((row) => {
|
||||
const currentValue = row[fieldKey];
|
||||
const sourceValue = row[sourceField];
|
||||
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
|
||||
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
||||
if (isEmpty && hasSource) {
|
||||
if (hasSource) {
|
||||
const sourceNum = parseFloat(String(sourceValue));
|
||||
return !isNaN(sourceNum) && sourceNum > 0;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [fieldKey, sourceField]);
|
||||
}, [sourceField]);
|
||||
|
||||
// Update fillable check on hover
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
@@ -1563,29 +1640,28 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
// Use setState() for efficient batch update with Immer
|
||||
useValidationStore.setState((draft) => {
|
||||
draft.rows.forEach((row, index) => {
|
||||
const currentValue = row[fieldKey];
|
||||
const sourceValue = row[sourceField];
|
||||
|
||||
// Only fill if current field is empty and source has a value
|
||||
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 !== '';
|
||||
|
||||
if (isEmpty && hasSource) {
|
||||
if (hasSource) {
|
||||
const sourceNum = parseFloat(String(sourceValue));
|
||||
if (!isNaN(sourceNum) && sourceNum > 0) {
|
||||
let msrp = sourceNum * multiplier;
|
||||
|
||||
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);
|
||||
if (cents === 0) {
|
||||
// .00 → subtract 1 cent to get .99
|
||||
msrp -= 0.01;
|
||||
} else if (cents === 98) {
|
||||
// .98 → add 1 cent to get .99
|
||||
msrp += 0.01;
|
||||
if (cents === 0 || cents === 98) {
|
||||
const adjustment = cents === 0 ? -0.01 : 0.01;
|
||||
const adjusted = (msrp + adjustment).toFixed(2);
|
||||
// Only apply if the adjusted value actually ends in .99
|
||||
if (adjusted.endsWith('.99')) {
|
||||
msrp = parseFloat(adjusted);
|
||||
}
|
||||
// Otherwise leave as-is
|
||||
}
|
||||
// Otherwise leave as-is (exact 2x)
|
||||
} else if (roundNine) {
|
||||
// For >2x with checkbox: round to nearest .X9
|
||||
msrp = roundToNine(msrp);
|
||||
@@ -1616,13 +1692,12 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
|
||||
useValidationStore.setState((draft) => {
|
||||
draft.rows.forEach((row, index) => {
|
||||
const currentValue = row[fieldKey];
|
||||
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 !== '';
|
||||
|
||||
if (isEmpty && hasSource) {
|
||||
if (hasSource) {
|
||||
const sourceNum = parseFloat(String(sourceValue));
|
||||
if (!isNaN(sourceNum) && sourceNum > 0) {
|
||||
draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2);
|
||||
@@ -1928,6 +2003,165 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
||||
|
||||
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
|
||||
*
|
||||
@@ -1958,18 +2192,46 @@ export const ValidationTable = () => {
|
||||
return offset;
|
||||
}, [fields]);
|
||||
|
||||
// Track horizontal scroll for sticky column shadow
|
||||
const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false);
|
||||
// Name column sticky toggle - when enabled, column sticks to left/right edges based on scroll
|
||||
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(() => {
|
||||
if (tableContainerRef.current && headerRef.current) {
|
||||
const scrollLeft = tableContainerRef.current.scrollLeft;
|
||||
const viewportWidth = tableContainerRef.current.clientWidth;
|
||||
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);
|
||||
}
|
||||
}, [nameColumnLeftOffset]);
|
||||
} else {
|
||||
setStickyDirection(null);
|
||||
}
|
||||
}
|
||||
}, [nameColumnLeftOffset, nameColumnWidth, nameColumnSticky]);
|
||||
|
||||
// Compute filtered indices AND row IDs in a single pass
|
||||
// This avoids calling getState() during render for each row
|
||||
@@ -2012,6 +2274,11 @@ export const ValidationTable = () => {
|
||||
return { filteredIndices: indices, rowIdMap: idMap };
|
||||
}, [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
|
||||
// Selection state is handled by isolated HeaderCheckbox component
|
||||
const columns = useMemo<ColumnDef<RowData>[]>(() => {
|
||||
@@ -2034,9 +2301,21 @@ export const ValidationTable = () => {
|
||||
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
|
||||
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
||||
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
|
||||
const renderHeader = () => {
|
||||
if (isNameColumn) {
|
||||
return (
|
||||
<NameColumnHeader
|
||||
label={field.label}
|
||||
isRequired={isRequired}
|
||||
isSticky={nameColumnSticky}
|
||||
onToggleSticky={toggleNameColumnSticky}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isPriceColumn) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
<span className="truncate">{field.label}</span>
|
||||
@@ -2073,7 +2361,7 @@ export const ValidationTable = () => {
|
||||
});
|
||||
|
||||
return [selectionColumn, templateColumn, ...dataColumns];
|
||||
}, [fields]); // CRITICAL: No selection-related deps!
|
||||
}, [fields, nameColumnSticky, toggleNameColumnSticky]); // Added nameColumnSticky dependencies
|
||||
|
||||
// Calculate total table width for horizontal scrolling
|
||||
const totalTableWidth = useMemo(() => {
|
||||
@@ -2109,20 +2397,31 @@ export const ValidationTable = () => {
|
||||
>
|
||||
{columns.map((column, index) => {
|
||||
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 (
|
||||
<div
|
||||
key={column.id || index}
|
||||
className={cn(
|
||||
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0",
|
||||
// Sticky header needs solid background matching the row's bg-muted/50 appearance
|
||||
isNameColumn && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
|
||||
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
|
||||
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground",
|
||||
// Use box-shadow for right border - renders more consistently
|
||||
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
||||
// 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={{
|
||||
width: column.size || 150,
|
||||
minWidth: column.size || 150,
|
||||
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'
|
||||
@@ -2159,7 +2458,8 @@ export const ValidationTable = () => {
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
totalRowCount={rowCount}
|
||||
isScrolledHorizontally={isScrolledHorizontally}
|
||||
nameColumnSticky={nameColumnSticky}
|
||||
stickyDirection={stickyDirection}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '../../../CreateProductCategoryDialog';
|
||||
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog';
|
||||
import { useTemplateManagement } from '../hooks/useTemplateManagement';
|
||||
import { SaveSessionButton } from '../../../components/SaveSessionDialog';
|
||||
|
||||
interface ValidationToolbarProps {
|
||||
rowCount: number;
|
||||
@@ -39,6 +40,38 @@ export const ValidationToolbar = ({
|
||||
const filters = useFilters();
|
||||
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
|
||||
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
||||
|
||||
@@ -112,15 +145,30 @@ export const ValidationToolbar = ({
|
||||
placeholder="Filter products..."
|
||||
value={filters.searchText}
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{/* Save session */}
|
||||
<SaveSessionButton />
|
||||
|
||||
{/* Add row */}
|
||||
<Button variant="outline" size="sm" onClick={() => handleAddRow()}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 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 { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -59,6 +59,7 @@ const ComboboxCellComponent = ({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||
const hasFetchedRef = useRef(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get store state for coordinating with popover close behavior
|
||||
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
||||
@@ -78,6 +79,10 @@ const ComboboxCellComponent = ({
|
||||
if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) {
|
||||
return;
|
||||
}
|
||||
// Reset scroll position when opening
|
||||
if (isOpen && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
setOpen(isOpen);
|
||||
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
|
||||
hasFetchedRef.current = true;
|
||||
@@ -90,6 +95,13 @@ const ComboboxCellComponent = ({
|
||||
[onFetchOptions, options.length, cellPopoverClosedAt]
|
||||
);
|
||||
|
||||
// Reset scroll position when search filters the list
|
||||
const handleSearchChange = useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle selection
|
||||
const handleSelect = useCallback(
|
||||
(selectedValue: string) => {
|
||||
@@ -105,6 +117,11 @@ const ComboboxCellComponent = ({
|
||||
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 (
|
||||
<div className="relative w-full">
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -130,7 +147,10 @@ const ComboboxCellComponent = ({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={`Search ${field.label.toLowerCase()}...`} />
|
||||
<CommandInput
|
||||
placeholder={`Search ${field.label.toLowerCase()}...`}
|
||||
onValueChange={handleSearchChange}
|
||||
/>
|
||||
<CommandList>
|
||||
{isLoadingOptions ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
@@ -140,11 +160,12 @@ const ComboboxCellComponent = ({
|
||||
<>
|
||||
<CommandEmpty>No {field.label.toLowerCase()} found.</CommandEmpty>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="max-h-[200px] overflow-y-auto overscroll-contain"
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
{sortedOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label} // cmdk filters by this value
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
*
|
||||
* Editable input cell for text, numbers, and price values.
|
||||
* 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 { AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
@@ -21,6 +26,17 @@ import type { ValidationError } from '../../store/types';
|
||||
import { ErrorType } from '../../store/types';
|
||||
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 */
|
||||
const POPOVER_CLOSE_DELAY = 150;
|
||||
|
||||
@@ -43,10 +59,14 @@ const InputCellComponent = ({
|
||||
errors,
|
||||
onBlur,
|
||||
}: InputCellProps) => {
|
||||
// Store the full precision value internally
|
||||
const [localValue, setLocalValue] = useState(String(value ?? ''));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
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
|
||||
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
||||
|
||||
@@ -57,6 +77,14 @@ const InputCellComponent = ({
|
||||
}
|
||||
}, [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
|
||||
// The store is updated on blur, which prevents thousands of subscription
|
||||
// checks per keystroke
|
||||
@@ -86,23 +114,13 @@ const InputCellComponent = ({
|
||||
}, [cellPopoverClosedAt]);
|
||||
|
||||
// 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(() => {
|
||||
setIsFocused(false);
|
||||
|
||||
let valueToSave = localValue;
|
||||
|
||||
// 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]);
|
||||
// Store the full precision value - no rounding here
|
||||
onBlur(localValue);
|
||||
}, [localValue, onBlur]);
|
||||
|
||||
// Process errors - show icon only for non-required errors when field has value
|
||||
// Don't show error icon while user is actively editing (focused)
|
||||
@@ -129,7 +147,7 @@ const InputCellComponent = ({
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={localValue}
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} 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 type { Field, SelectOption } from '../../../../types';
|
||||
import type { ValidationError } from '../../store/types';
|
||||
@@ -55,9 +55,10 @@ interface MultilineInputProps {
|
||||
const MultilineInputComponent = ({
|
||||
field,
|
||||
value,
|
||||
productIndex,
|
||||
isValidating,
|
||||
errors,
|
||||
onChange,
|
||||
onChange: _onChange, // Unused - onBlur handles both update and validation
|
||||
onBlur,
|
||||
aiSuggestion,
|
||||
isAiValidating,
|
||||
@@ -68,10 +69,25 @@ const MultilineInputComponent = ({
|
||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
||||
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
||||
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 preventReopenRef = useRef(false);
|
||||
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
|
||||
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
|
||||
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
||||
@@ -112,11 +128,92 @@ const MultilineInputComponent = ({
|
||||
}
|
||||
}, [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)
|
||||
const wasPopoverRecentlyClosed = useCallback(() => {
|
||||
return Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY;
|
||||
}, [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
|
||||
const handleTriggerClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -136,23 +233,26 @@ const MultilineInputComponent = ({
|
||||
|
||||
// Only process if not already open
|
||||
if (!popoverOpen) {
|
||||
updatePopoverWidth();
|
||||
setPopoverOpen(true);
|
||||
// Initialize edit value from the current display
|
||||
setEditValue(localDisplayValue || String(value ?? ''));
|
||||
// Initialize edit value from the current display and track it for change detection
|
||||
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)
|
||||
const handleClosePopover = useCallback(() => {
|
||||
// Only process if we have changes
|
||||
if (editValue !== value || editValue !== localDisplayValue) {
|
||||
// Only process if the user actually changed the value
|
||||
if (editValue !== initialEditValueRef.current) {
|
||||
// Update local display immediately
|
||||
setLocalDisplayValue(editValue);
|
||||
|
||||
// Queue up the change
|
||||
onChange(editValue);
|
||||
// onBlur handles both cell update and validation (don't call onChange first
|
||||
// as it would update the store before onBlur can capture previousValue)
|
||||
onBlur(editValue);
|
||||
}
|
||||
|
||||
@@ -168,7 +268,7 @@ const MultilineInputComponent = ({
|
||||
setTimeout(() => {
|
||||
preventReopenRef.current = false;
|
||||
}, 100);
|
||||
}, [editValue, value, localDisplayValue, onChange, onBlur]);
|
||||
}, [editValue, onBlur]);
|
||||
|
||||
// Handle popover open/close (called by Radix for click-outside and escape key)
|
||||
const handlePopoverOpenChange = useCallback(
|
||||
@@ -183,10 +283,10 @@ const MultilineInputComponent = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// This is a click-outside close - save changes and signal other cells
|
||||
if (editValue !== value || editValue !== localDisplayValue) {
|
||||
// This is a click-outside close - only save if user actually changed the value
|
||||
if (editValue !== initialEditValueRef.current) {
|
||||
setLocalDisplayValue(editValue);
|
||||
onChange(editValue);
|
||||
// onBlur handles both cell update and validation
|
||||
onBlur(editValue);
|
||||
}
|
||||
|
||||
@@ -205,28 +305,33 @@ const MultilineInputComponent = ({
|
||||
if (wasPopoverRecentlyClosed()) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
[value, popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onChange, onBlur, setCellPopoverClosed]
|
||||
[popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onBlur, setCellPopoverClosed, updatePopoverWidth, value]
|
||||
);
|
||||
|
||||
// Handle direct input change
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEditValue(e.target.value);
|
||||
}, []);
|
||||
autoResizeTextarea(e.target);
|
||||
}, [autoResizeTextarea]);
|
||||
|
||||
// Handle accepting the AI suggestion (possibly edited)
|
||||
const handleAcceptSuggestion = useCallback(() => {
|
||||
// Use the edited suggestion
|
||||
setEditValue(editedSuggestion);
|
||||
setLocalDisplayValue(editedSuggestion);
|
||||
onChange(editedSuggestion);
|
||||
// onBlur handles both cell update and validation
|
||||
onBlur(editedSuggestion);
|
||||
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
|
||||
setAiSuggestionExpanded(false);
|
||||
}, [editedSuggestion, onChange, onBlur, onDismissAiSuggestion]);
|
||||
}, [editedSuggestion, onBlur, onDismissAiSuggestion]);
|
||||
|
||||
// Handle dismissing the AI suggestion
|
||||
const handleDismissSuggestion = useCallback(() => {
|
||||
@@ -243,7 +348,7 @@ const MultilineInputComponent = ({
|
||||
|
||||
return (
|
||||
<div className="w-full relative" ref={cellRef}>
|
||||
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
|
||||
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange} modal>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -270,9 +375,13 @@ const MultilineInputComponent = ({
|
||||
if (wasPopoverRecentlyClosed()) {
|
||||
return;
|
||||
}
|
||||
updatePopoverWidth();
|
||||
setAiSuggestionExpanded(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"
|
||||
title="View AI suggestion"
|
||||
@@ -302,14 +411,30 @@ const MultilineInputComponent = ({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<PopoverContent
|
||||
className="p-0 shadow-lg rounded-md"
|
||||
style={{ width: Math.max(cellRef.current?.offsetWidth || 400, 400) }}
|
||||
className="p-0 shadow-lg rounded-md max-lg:!w-[95vw]"
|
||||
style={{ width: hasAiSuggestion ? popoverWidth * 2 : popoverWidth }}
|
||||
align="start"
|
||||
side="bottom"
|
||||
alignOffset={0}
|
||||
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
|
||||
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}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{/* Close button */}
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -321,24 +446,48 @@ const MultilineInputComponent = ({
|
||||
</Button>
|
||||
|
||||
{/* 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
|
||||
ref={mainTextareaRef}
|
||||
value={editValue}
|
||||
onChange={handleChange}
|
||||
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'}...`}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{hasAiSuggestion && <div className="h-[43px] flex-shrink-0 hidden lg:block" />}
|
||||
</div></div>
|
||||
{/* AI Suggestion section */}
|
||||
{hasAiSuggestion && (
|
||||
<div className="border-t border-purple-200 dark:border-purple-800 bg-purple-50/80 dark:bg-purple-950/30">
|
||||
{/* Collapsed header - always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAiSuggestionExpanded(!aiSuggestionExpanded)}
|
||||
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 data-col="right" className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
|
||||
{/* Measured header + issues area (mirrored as spacer on the left) */}
|
||||
<div ref={aiHeaderRef} className="flex-shrink-0">
|
||||
{/* Header */}
|
||||
<div className="w-full flex items-center justify-between px-3 py-2">
|
||||
<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">
|
||||
@@ -348,19 +497,11 @@ const MultilineInputComponent = ({
|
||||
({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>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{aiSuggestionExpanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
{/* Issues list */}
|
||||
{aiIssues.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 px-3 pb-3">
|
||||
{aiIssues.map((issue, index) => (
|
||||
<div
|
||||
key={index}
|
||||
@@ -372,22 +513,29 @@ const MultilineInputComponent = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
|
||||
{/* Editable suggestion */}
|
||||
<div>
|
||||
<div className="text-xs text-purple-500 dark:text-purple-400 mb-1 font-medium">
|
||||
<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)}
|
||||
onChange={(e) => {
|
||||
setEditedSuggestion(e.target.value);
|
||||
autoResizeTextarea(e.target);
|
||||
}}
|
||||
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"
|
||||
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">
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -403,11 +551,10 @@ const MultilineInputComponent = ({
|
||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||
onClick={handleDismissSuggestion}
|
||||
>
|
||||
Dismiss
|
||||
Ignore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ const SelectCellComponent = ({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isFetchingOptions, setIsFetchingOptions] = useState(false);
|
||||
const hasFetchedRef = useRef(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get store state for coordinating with popover close behavior
|
||||
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
||||
@@ -98,11 +99,22 @@ const SelectCellComponent = ({
|
||||
setIsFetchingOptions(false);
|
||||
}
|
||||
}
|
||||
// Reset scroll position when opening
|
||||
if (isOpen && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
setOpen(isOpen);
|
||||
},
|
||||
[onFetchOptions, options.length, cellPopoverClosedAt]
|
||||
);
|
||||
|
||||
// Reset scroll position when search filters the list
|
||||
const handleSearchChange = useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle selection
|
||||
const handleSelect = useCallback(
|
||||
(selectedValue: string) => {
|
||||
@@ -118,6 +130,11 @@ const SelectCellComponent = ({
|
||||
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
|
||||
// IMPORTANT: We need to match against both string and number value types
|
||||
const displayLabel = useMemo(() => {
|
||||
@@ -182,7 +199,11 @@ const SelectCellComponent = ({
|
||||
sideOffset={4}
|
||||
>
|
||||
<Command shouldFilter={true}>
|
||||
<CommandInput placeholder="Search..." className="h-9" />
|
||||
<CommandInput
|
||||
placeholder="Search..."
|
||||
className="h-9"
|
||||
onValueChange={handleSearchChange}
|
||||
/>
|
||||
<CommandList>
|
||||
{isLoadingOptions ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
@@ -192,11 +213,12 @@ const SelectCellComponent = ({
|
||||
<>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="max-h-[200px] overflow-y-auto overscroll-contain"
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
{sortedOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useTemplates,
|
||||
useTemplatesLoading,
|
||||
useTemplateState,
|
||||
useFields,
|
||||
} from '../store/selectors';
|
||||
import { toast } from 'sonner';
|
||||
import config from '@/config';
|
||||
@@ -41,6 +42,7 @@ export const useTemplateManagement = () => {
|
||||
const templates = useTemplates();
|
||||
const templatesLoading = useTemplatesLoading();
|
||||
const templateState = useTemplateState();
|
||||
const fields = useFields();
|
||||
|
||||
// Store actions
|
||||
const setTemplates = useValidationStore((state) => state.setTemplates);
|
||||
@@ -101,9 +103,17 @@ export const useTemplateManagement = () => {
|
||||
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(
|
||||
([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
|
||||
@@ -295,6 +305,7 @@ export const useTemplateManagement = () => {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
(templateId: string | null): string => {
|
||||
@@ -303,12 +314,20 @@ export const useTemplateManagement = () => {
|
||||
const template = templates.find((t) => t.id.toString() === templateId);
|
||||
if (!template) return '';
|
||||
|
||||
// Return "Brand - Product Type" format
|
||||
const company = template.company || 'Unknown';
|
||||
// Look up company name from field options
|
||||
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';
|
||||
return `${company} - ${productType}`;
|
||||
return `${companyName} - ${productType}`;
|
||||
},
|
||||
[templates]
|
||||
[templates, fields]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -185,22 +185,14 @@ export const useValidationActions = () => {
|
||||
*
|
||||
* With 100 rows × 30 fields, the old approach would trigger ~3000 individual
|
||||
* 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 { 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)
|
||||
const allErrors = new Map<number, Record<string, ValidationError[]>>();
|
||||
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
|
||||
for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) {
|
||||
const row = currentRows[rowIndex];
|
||||
@@ -221,20 +213,11 @@ export const useValidationActions = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Round currency fields to 2 decimal places on initial load
|
||||
for (const priceFieldKey of priceFields) {
|
||||
const value = row[priceFieldKey];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
const numValue = parseFloat(String(value));
|
||||
if (!isNaN(numValue)) {
|
||||
const rounded = numValue.toFixed(2);
|
||||
if (String(value) !== rounded) {
|
||||
// Update the cell with rounded value (batched later)
|
||||
updateCellAction(rowIndex, priceFieldKey, rounded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// NOTE: We no longer round price fields on initial load.
|
||||
// Full precision is preserved internally (e.g., "3.625") for accurate calculations.
|
||||
// - Display: InputCell shows 2 decimals when not focused
|
||||
// - Calculations: 2x button uses full precision
|
||||
// - API submission: getCleanedData() formats to 2 decimals
|
||||
|
||||
// Validate each field
|
||||
for (const field of currentFields) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 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 { useValidationStore } from './store/validationStore';
|
||||
import { useInitPhase, useIsReady } from './store/selectors';
|
||||
@@ -24,6 +24,36 @@ import config from '@/config';
|
||||
import type { ValidationStepProps } from './store/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
|
||||
*/
|
||||
@@ -105,6 +135,7 @@ export const ValidationStep = ({
|
||||
const templatesLoadedRef = useRef(false);
|
||||
const upcValidationStartedRef = useRef(false);
|
||||
const fieldValidationStartedRef = useRef(false);
|
||||
const lastDataFingerprintRef = useRef<string | null>(null);
|
||||
|
||||
// Debug logging
|
||||
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
|
||||
@@ -132,12 +163,25 @@ export const ValidationStep = ({
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
// Get current store state to check if we're returning to an already-initialized store
|
||||
const storeRows = useValidationStore((state) => state.rows);
|
||||
// Create a fingerprint of the incoming data to detect changes
|
||||
const dataFingerprint = useMemo(() => createDataFingerprint(initialData), [initialData]);
|
||||
|
||||
// Initialize store with data
|
||||
useEffect(() => {
|
||||
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)
|
||||
// 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
|
||||
// This happens when navigating back from ImageUploadStep - the store still has
|
||||
// all the validated data, so we don't need to re-run the initialization sequence.
|
||||
// We check that the store is 'ready' and has matching row count to avoid
|
||||
// false positives from stale store 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');
|
||||
// with the SAME data. This happens when navigating back from ImageUploadStep.
|
||||
// We compare fingerprints to detect if the data has actually changed.
|
||||
if (initPhase === 'ready' && !dataHasChanged && lastDataFingerprintRef.current === dataFingerprint) {
|
||||
console.log('[ValidationStep] Skipping init - returning to already-ready store with same data');
|
||||
initStartedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
initStartedRef.current = true;
|
||||
lastDataFingerprintRef.current = dataFingerprint;
|
||||
|
||||
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
|
||||
|
||||
@@ -172,7 +215,7 @@ export const ValidationStep = ({
|
||||
console.log('[ValidationStep] Calling initialize()');
|
||||
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
|
||||
console.log('[ValidationStep] initialize() called');
|
||||
}, [initialData, file, initialize, initPhase, storeRows.length]);
|
||||
}, [initialData, file, initialize, initPhase, dataFingerprint]);
|
||||
|
||||
// Update fields when options are loaded
|
||||
// CRITICAL: Check store state (not ref) because initialize() resets the store
|
||||
|
||||
@@ -215,6 +215,20 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
|
||||
deleteRows: (rowIndexes: number[]) => {
|
||||
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)
|
||||
const sorted = [...rowIndexes].sort((a, b) => b - a);
|
||||
sorted.forEach((index) => {
|
||||
@@ -949,9 +963,25 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
|
||||
getCleanedData: (): CleanRowData[] => {
|
||||
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) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -34,10 +34,16 @@ export function calculateEanCheckDigit(eanBody: string): number {
|
||||
|
||||
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
|
||||
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)) {
|
||||
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) {
|
||||
@@ -49,15 +55,18 @@ export function correctUpcValue(rawValue: unknown): { corrected: string; changed
|
||||
const body = str.slice(0, 11);
|
||||
const check = calculateUpcCheckDigit(body);
|
||||
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) {
|
||||
const body = str.slice(0, 12);
|
||||
const check = calculateEanCheckDigit(body);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export type SubmitOptions = {
|
||||
targetEnvironment: "dev" | "prod"
|
||||
useTestDataSource: boolean
|
||||
skipApiSubmission?: boolean
|
||||
showNewProduct?: boolean
|
||||
}
|
||||
|
||||
export type RsiProps<T extends string> = {
|
||||
|
||||
260
inventory/src/components/ui/carousel.tsx
Normal file
260
inventory/src/components/ui/carousel.tsx
Normal 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,
|
||||
}
|
||||
@@ -2,7 +2,7 @@ const isDev = import.meta.env.DEV;
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
|
||||
// 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 = {
|
||||
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',
|
||||
|
||||
390
inventory/src/contexts/ImportSessionContext.tsx
Normal file
390
inventory/src/contexts/ImportSessionContext.tsx
Normal 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;
|
||||
}
|
||||
96
inventory/src/hooks/useImportAutosave.ts
Normal file
96
inventory/src/hooks/useImportAutosave.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/ap
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
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"] };
|
||||
|
||||
interface BackendProductResult {
|
||||
@@ -271,200 +271,6 @@ export function Import() {
|
||||
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
||||
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
|
||||
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
||||
queryKey: ["import-field-options"],
|
||||
@@ -734,9 +540,13 @@ export function Import() {
|
||||
normalizedProductImages = rawProductImages;
|
||||
}
|
||||
|
||||
// Preserve show_new_product flag if it was set
|
||||
const showNewProduct = (row as Record<string, unknown>).show_new_product;
|
||||
|
||||
return {
|
||||
...baseValues,
|
||||
product_images: normalizedProductImages,
|
||||
...(showNewProduct === true && { show_new_product: true }),
|
||||
} as NormalizedProduct;
|
||||
});
|
||||
|
||||
|
||||
55
inventory/src/pages/ProductEditor.tsx
Normal file
55
inventory/src/pages/ProductEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
inventory/src/services/importSessionApi.ts
Normal file
139
inventory/src/services/importSessionApi.ts
Normal 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,
|
||||
};
|
||||
98
inventory/src/services/productEditor.ts
Normal file
98
inventory/src/services/productEditor.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
158
inventory/src/types/importSession.ts
Normal file
158
inventory/src/types/importSession.ts
Normal 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
@@ -90,31 +90,31 @@ export default defineConfig(({ mode }) => {
|
||||
cookieDomainRewrite: "localhost",
|
||||
},
|
||||
"/api/aircall": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
"/api/klaviyo": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
"/api/meta": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
"/api/gorgias": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
"/api/dashboard-analytics": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
cookieDomainRewrite: {
|
||||
@@ -122,25 +122,25 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
"/api/typeform": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
"/api/acot": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
"/api/clarity": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
"/api": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
@@ -161,14 +161,14 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
"/dashboard-auth": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
rewrite: (path) => path.replace("/dashboard-auth", "/auth"),
|
||||
},
|
||||
"/auth-inv": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
@@ -195,7 +195,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
"/chat-api": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
@@ -216,7 +216,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
"/uploads": {
|
||||
target: "https://acot.site",
|
||||
target: "https://tools.acherryontop.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
|
||||
Reference in New Issue
Block a user