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_SUPPLIER_PREFIX_LEADING_DIGIT = '4';
|
||||||
const UPC_MAX_SEQUENCE = 99999;
|
const UPC_MAX_SEQUENCE = 99999;
|
||||||
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes
|
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const categoriesAggregateRouter = require('./routes/categoriesAggregate');
|
|||||||
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
||||||
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
||||||
const htsLookupRouter = require('./routes/hts-lookup');
|
const htsLookupRouter = require('./routes/hts-lookup');
|
||||||
|
const importSessionsRouter = require('./routes/import-sessions');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
const envPath = '/var/www/html/inventory/.env';
|
const envPath = '/var/www/html/inventory/.env';
|
||||||
@@ -130,6 +131,7 @@ async function startServer() {
|
|||||||
app.use('/api/ai-prompts', aiPromptsRouter);
|
app.use('/api/ai-prompts', aiPromptsRouter);
|
||||||
app.use('/api/reusable-images', reusableImagesRouter);
|
app.use('/api/reusable-images', reusableImagesRouter);
|
||||||
app.use('/api/hts-lookup', htsLookupRouter);
|
app.use('/api/hts-lookup', htsLookupRouter);
|
||||||
|
app.use('/api/import-sessions', importSessionsRouter);
|
||||||
|
|
||||||
// Basic health check route
|
// Basic health check route
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
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-scroll-area": "^1.2.2",
|
||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.4.4",
|
"framer-motion": "^12.4.4",
|
||||||
"immer": "^11.1.3",
|
"immer": "^11.1.3",
|
||||||
"input-otp": "^1.4.1",
|
"input-otp": "^1.4.1",
|
||||||
@@ -1498,6 +1499,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
|
||||||
@@ -1633,6 +1652,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-compose-refs": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||||
@@ -1699,6 +1736,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-direction": {
|
"node_modules/@radix-ui/react-direction": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
|
||||||
@@ -1891,6 +1946,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popover": {
|
"node_modules/@radix-ui/react-popover": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz",
|
||||||
@@ -1928,6 +2001,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popper": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
|
||||||
@@ -2031,6 +2122,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-progress": {
|
"node_modules/@radix-ui/react-progress": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz",
|
||||||
@@ -2192,6 +2301,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-separator": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz",
|
||||||
@@ -2216,12 +2343,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.1.2",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.1"
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -2233,6 +2360,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-switch": {
|
"node_modules/@radix-ui/react-switch": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz",
|
||||||
@@ -2414,6 +2556,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
||||||
@@ -4794,6 +4954,34 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/embla-carousel": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/embla-carousel-react": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"embla-carousel": "8.6.0",
|
||||||
|
"embla-carousel-reactive-utils": "8.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/embla-carousel-reactive-utils": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"embla-carousel": "8.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.4.4",
|
"framer-motion": "^12.4.4",
|
||||||
"immer": "^11.1.3",
|
"immer": "^11.1.3",
|
||||||
"input-otp": "^1.4.1",
|
"input-otp": "^1.4.1",
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
|||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
|
const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
|
||||||
|
|
||||||
// 3. Product import - separate chunk
|
// 3. Product import - separate chunk
|
||||||
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
|
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
|
||||||
|
|
||||||
|
// Product editor
|
||||||
|
const ProductEditor = lazy(() => import('./pages/ProductEditor'));
|
||||||
|
|
||||||
// 4. Chat archive - separate chunk
|
// 4. Chat archive - separate chunk
|
||||||
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
|
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
|
||||||
|
|
||||||
@@ -185,6 +188,15 @@ function App() {
|
|||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* Product editor */}
|
||||||
|
<Route path="/product-editor" element={
|
||||||
|
<Protected page="product_editor">
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<ProductEditor />
|
||||||
|
</Suspense>
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Product import - separate chunk */}
|
{/* Product import - separate chunk */}
|
||||||
<Route path="/import" element={
|
<Route path="/import" element={
|
||||||
<Protected page="import">
|
<Protected page="import">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Percent,
|
Percent,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
FilePenLine,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -113,6 +114,12 @@ const toolsItems = [
|
|||||||
icon: IconCrystalBall,
|
icon: IconCrystalBall,
|
||||||
url: "/forecasting",
|
url: "/forecasting",
|
||||||
permission: "access:forecasting"
|
permission: "access:forecasting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Product Editor",
|
||||||
|
icon: FilePenLine,
|
||||||
|
url: "/product-editor",
|
||||||
|
permission: "access:product_editor"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
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 type { RsiProps } from "./types"
|
||||||
import { ModalWrapper } from "./components/ModalWrapper"
|
import { ModalWrapper } from "./components/ModalWrapper"
|
||||||
import { translations } from "./translationsRSIProps"
|
import { translations } from "./translationsRSIProps"
|
||||||
|
import { ImportSessionProvider } from "@/contexts/ImportSessionContext"
|
||||||
|
|
||||||
// Simple empty theme placeholder
|
// Simple empty theme placeholder
|
||||||
export const defaultTheme = {}
|
export const defaultTheme = {}
|
||||||
@@ -29,10 +30,12 @@ export const ReactSpreadsheetImport = <T extends string>(propsWithoutDefaults: R
|
|||||||
props.translations !== translations ? merge(translations, props.translations) : translations
|
props.translations !== translations ? merge(translations, props.translations) : translations
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers rsiValues={{ ...props, translations: mergedTranslations }}>
|
<ImportSessionProvider>
|
||||||
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
|
<Providers rsiValues={{ ...props, translations: mergedTranslations }}>
|
||||||
<Steps />
|
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
|
||||||
</ModalWrapper>
|
<Steps />
|
||||||
</Providers>
|
</ModalWrapper>
|
||||||
|
</Providers>
|
||||||
|
</ImportSessionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogClose,
|
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import {
|
import { X } from "lucide-react"
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
AlertDialogPortal,
|
|
||||||
AlertDialogOverlay,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { useRsi } from "../hooks/useRsi"
|
import { useRsi } from "../hooks/useRsi"
|
||||||
import { useState, useCallback } from "react"
|
import { useState, useCallback, useRef } from "react"
|
||||||
|
import { CloseConfirmationDialog } from "./CloseConfirmationDialog"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -29,76 +15,85 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
|
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
|
||||||
const { rtl, translations } = useRsi()
|
const { rtl } = useRsi()
|
||||||
const [showCloseAlert, setShowCloseAlert] = useState(false)
|
const [showCloseAlert, setShowCloseAlert] = useState(false)
|
||||||
|
// Guard: when we're programmatically closing, don't re-show the alert
|
||||||
// Create a handler that resets scroll positions before closing
|
const closingRef = useRef(false)
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
// Reset all scroll positions in the dialog
|
// Called after user confirms close in the dialog
|
||||||
|
const handleConfirmClose = useCallback(() => {
|
||||||
|
// Dismiss the confirmation dialog
|
||||||
|
setShowCloseAlert(false)
|
||||||
|
|
||||||
|
// Mark that we're intentionally closing so onOpenChange doesn't re-trigger the alert
|
||||||
|
closingRef.current = true
|
||||||
|
|
||||||
|
// Reset scroll positions
|
||||||
const scrollContainers = document.querySelectorAll('.overflow-auto, .overflow-scroll');
|
const scrollContainers = document.querySelectorAll('.overflow-auto, .overflow-scroll');
|
||||||
scrollContainers.forEach(container => {
|
scrollContainers.forEach(container => {
|
||||||
if (container instanceof HTMLElement) {
|
if (container instanceof HTMLElement) {
|
||||||
// Reset scroll position to top-left
|
|
||||||
container.scrollTop = 0;
|
container.scrollTop = 0;
|
||||||
container.scrollLeft = 0;
|
container.scrollLeft = 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call the original onClose handler
|
// Close the main dialog
|
||||||
onClose();
|
onClose();
|
||||||
|
|
||||||
|
// Reset the guard after a tick (after Radix fires onOpenChange)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
closingRef.current = false
|
||||||
|
})
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Radix fires this when something tries to change the dialog's open state
|
||||||
|
// (e.g. focus loss, internal close). We intercept to show our confirmation instead.
|
||||||
|
const handleDialogOpenChange = useCallback((open: boolean) => {
|
||||||
|
if (!open && !closingRef.current) {
|
||||||
|
// Something is trying to close the dialog — show confirmation
|
||||||
|
setShowCloseAlert(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={() => setShowCloseAlert(true)} modal>
|
{/*
|
||||||
<DialogPortal>
|
NOTE: We use DialogPrimitive.Portal/Overlay/Content directly instead of the
|
||||||
<DialogOverlay className="bg-background/80 backdrop-blur-sm" />
|
shadcn DialogContent component. The shadcn DialogContent internally renders its
|
||||||
<DialogContent
|
own Portal + Overlay, which would create duplicate portals/overlays and break
|
||||||
|
pointer-events for nested Popovers (e.g., select dropdowns).
|
||||||
|
*/}
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleDialogOpenChange} modal>
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-primary/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
|
<DialogPrimitive.Content
|
||||||
onEscapeKeyDown={(e) => {
|
onEscapeKeyDown={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setShowCloseAlert(true)
|
setShowCloseAlert(true)
|
||||||
}}
|
}}
|
||||||
onPointerDownOutside={(e) => e.preventDefault()}
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] h-[calc(100%-2rem)] max-w-[100vw] max-h-[100vh] flex flex-col overflow-hidden rounded-lg border bg-background p-0 shadow-lg sm:w-[calc(100%-3rem)] sm:h-[calc(100%-3rem)] md:w-[calc(100%-4rem)] md:h-[calc(100%-4rem)]"
|
className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] h-[calc(100%-2rem)] max-w-[100vw] max-h-[100vh] flex flex-col overflow-hidden rounded-lg border bg-background p-0 shadow-lg sm:w-[calc(100%-3rem)] sm:h-[calc(100%-3rem)] md:w-[calc(100%-4rem)] md:h-[calc(100%-4rem)]"
|
||||||
>
|
>
|
||||||
<AlertDialog>
|
<button
|
||||||
<AlertDialogTrigger asChild>
|
type="button"
|
||||||
<DialogClose className="absolute right-4 top-4" onClick={(e) => {
|
className="absolute right-4 top-4 z-10 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
e.preventDefault()
|
onClick={() => setShowCloseAlert(true)}
|
||||||
setShowCloseAlert(true)
|
>
|
||||||
}} />
|
<X className="h-4 w-4" />
|
||||||
</AlertDialogTrigger>
|
<span className="sr-only">Close</span>
|
||||||
</AlertDialog>
|
</button>
|
||||||
<div dir={rtl ? "rtl" : "ltr"} className="flex-1 overflow-auto">
|
<div dir={rtl ? "rtl" : "ltr"} className="flex-1 overflow-auto">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPrimitive.Portal>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<AlertDialog open={showCloseAlert} onOpenChange={setShowCloseAlert}>
|
<CloseConfirmationDialog
|
||||||
<AlertDialogPortal>
|
open={showCloseAlert}
|
||||||
<AlertDialogOverlay className="z-[1400]" />
|
onOpenChange={setShowCloseAlert}
|
||||||
<AlertDialogContent className="z-[1500]">
|
onConfirmClose={handleConfirmClose}
|
||||||
<AlertDialogHeader>
|
/>
|
||||||
<AlertDialogTitle>
|
|
||||||
{translations.alerts.confirmClose.headerTitle}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{translations.alerts.confirmClose.bodyText}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onClick={() => setShowCloseAlert(false)}>
|
|
||||||
{translations.alerts.confirmClose.cancelButtonTitle}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleClose}>
|
|
||||||
{translations.alerts.confirmClose.exitButtonTitle}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialogPortal>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
@@ -24,8 +24,22 @@ import { useBulkImageUpload } from "./hooks/useBulkImageUpload";
|
|||||||
import { useUrlImageUpload } from "./hooks/useUrlImageUpload";
|
import { useUrlImageUpload } from "./hooks/useUrlImageUpload";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { AuthContext } from "@/contexts/AuthContext";
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
import { useImportAutosave } from "@/hooks/useImportAutosave";
|
||||||
|
import { useImportSession } from "@/contexts/ImportSessionContext";
|
||||||
|
import { SaveSessionButton } from "../../components/SaveSessionDialog";
|
||||||
import type { SubmitOptions } from "../../types";
|
import type { SubmitOptions } from "../../types";
|
||||||
|
import type { ImportSessionData } from "@/types/importSession";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: Product[];
|
data: Product[];
|
||||||
@@ -48,7 +62,12 @@ export const ImageUploadStep = ({
|
|||||||
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
|
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
|
||||||
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
|
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
|
||||||
const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false);
|
const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false);
|
||||||
|
const [showNewProduct, setShowNewProduct] = useState<boolean>(false);
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Import session context for cleanup on submit and global selections
|
||||||
|
const { deleteSession: deleteImportSession, getGlobalSelections } = useImportSession();
|
||||||
|
|
||||||
// Use our hook for product images initialization
|
// Use our hook for product images initialization
|
||||||
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
|
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
|
||||||
|
|
||||||
@@ -89,7 +108,32 @@ export const ImageUploadStep = ({
|
|||||||
data,
|
data,
|
||||||
handleImageUpload
|
handleImageUpload
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Autosave hook for session persistence
|
||||||
|
const { markDirty } = useImportAutosave({
|
||||||
|
enabled: true,
|
||||||
|
step: 'imageUpload',
|
||||||
|
getSessionData: useCallback((): ImportSessionData => {
|
||||||
|
return {
|
||||||
|
current_step: 'imageUpload',
|
||||||
|
data: data as any[], // Product data
|
||||||
|
product_images: productImages,
|
||||||
|
global_selections: getGlobalSelections(),
|
||||||
|
};
|
||||||
|
}, [data, productImages, getGlobalSelections]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark dirty when images change (use ref to avoid depending on markDirty identity)
|
||||||
|
const markDirtyRef = useRef(markDirty);
|
||||||
|
markDirtyRef.current = markDirty;
|
||||||
|
const prevProductImagesRef = useRef(productImages);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevProductImagesRef.current !== productImages) {
|
||||||
|
prevProductImagesRef.current = productImages;
|
||||||
|
markDirtyRef.current();
|
||||||
|
}
|
||||||
|
}, [productImages]);
|
||||||
|
|
||||||
// Set up sensors for drag and drop with enhanced configuration
|
// Set up sensors for drag and drop with enhanced configuration
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -171,33 +215,58 @@ export const ImageUploadStep = ({
|
|||||||
return {
|
return {
|
||||||
...product,
|
...product,
|
||||||
// Store as comma-separated string to ensure compatibility
|
// Store as comma-separated string to ensure compatibility
|
||||||
product_images: images.join(',')
|
product_images: images.join(','),
|
||||||
|
// Add show_new_product flag if enabled
|
||||||
|
...(showNewProduct && { show_new_product: true })
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitOptions: SubmitOptions = {
|
const submitOptions: SubmitOptions = {
|
||||||
targetEnvironment,
|
targetEnvironment,
|
||||||
useTestDataSource,
|
useTestDataSource,
|
||||||
skipApiSubmission,
|
skipApiSubmission,
|
||||||
|
showNewProduct,
|
||||||
};
|
};
|
||||||
|
|
||||||
await onSubmit(updatedData, file, submitOptions);
|
await onSubmit(updatedData, file, submitOptions);
|
||||||
|
|
||||||
|
// Delete the import session on successful submit
|
||||||
|
try {
|
||||||
|
await deleteImportSession();
|
||||||
|
} catch (err) {
|
||||||
|
// Non-critical - log but don't fail the submission
|
||||||
|
console.warn('Failed to delete import session:', err);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit error:', error);
|
console.error('Submit error:', error);
|
||||||
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission]);
|
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission, showNewProduct, deleteImportSession]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
|
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden relative">
|
||||||
|
{/* Full-screen loading overlay during submit */}
|
||||||
|
{isSubmitting && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-4">
|
||||||
|
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||||
|
<div className="text-lg font-medium">Submitting products...</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Please wait while your import is being processed</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header - fixed at top */}
|
{/* Header - fixed at top */}
|
||||||
<div className="px-8 py-6 bg-background shrink-0">
|
<div className="px-8 py-6 bg-background shrink-0">
|
||||||
<h2 className="text-2xl font-semibold">Add Product Images</h2>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<div>
|
||||||
Drag images to reorder them or move them between products.
|
<h2 className="text-2xl font-semibold">Add Product Images</h2>
|
||||||
</p>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Drag images to reorder them or move them between products.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SaveSessionButton />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content area - only this part scrolls */}
|
{/* Content area - only this part scrolls */}
|
||||||
@@ -297,6 +366,24 @@ export const ImageUploadStep = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-6">
|
<div className="flex flex-1 flex-wrap items-center justify-end gap-6">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Switch
|
||||||
|
id="product-import-show-new-product"
|
||||||
|
checked={showNewProduct}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setShowConfirmDialog(true);
|
||||||
|
} else {
|
||||||
|
setShowNewProduct(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="product-import-show-new-product" className="text-sm font-medium">
|
||||||
|
Show these products immediately
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{hasDebugPermission && (
|
{hasDebugPermission && (
|
||||||
<div className="flex gap-4 text-sm">
|
<div className="flex gap-4 text-sm">
|
||||||
{!skipApiSubmission && (
|
{!skipApiSubmission && (
|
||||||
@@ -351,6 +438,30 @@ export const ImageUploadStep = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Show products immediately?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will create all of these products with the "hide" option NOT set, so they will be immediately visible on the site when received or put on pre-order. Do NOT use this option for products we're not allowed to show yet.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setShowConfirmDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewProduct(true);
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yes, show immediately
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const getFullImageUrl = (url: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, it's a relative URL, prepend the domain
|
// Otherwise, it's a relative URL, prepend the domain
|
||||||
const baseUrl = 'https://acot.site';
|
const baseUrl = 'https://tools.acherryontop.com';
|
||||||
// Make sure url starts with / for path
|
// Make sure url starts with / for path
|
||||||
const path = url.startsWith('/') ? url : `/${url}`;
|
const path = url.startsWith('/') ? url : `/${url}`;
|
||||||
return `${baseUrl}${path}`;
|
return `${baseUrl}${path}`;
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const useProductImagesInit = (data: Product[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, it's a relative URL, prepend the domain
|
// Otherwise, it's a relative URL, prepend the domain
|
||||||
const baseUrl = 'https://acot.site';
|
const baseUrl = 'https://tools.acherryontop.com';
|
||||||
// Make sure url starts with / for path
|
// Make sure url starts with / for path
|
||||||
const path = url.startsWith('/') ? url : `/${url}`;
|
const path = url.startsWith('/') ? url : `/${url}`;
|
||||||
return `${baseUrl}${path}`;
|
return `${baseUrl}${path}`;
|
||||||
|
|||||||
@@ -1,36 +1,37 @@
|
|||||||
import { StepState, StepType, UploadFlow } from "./UploadFlow"
|
import { StepState, StepType, UploadFlow } from "./UploadFlow"
|
||||||
import { useRsi } from "../hooks/useRsi"
|
import { useRsi } from "../hooks/useRsi"
|
||||||
import { useRef, useState, useEffect } from "react"
|
import { useRef, useState, useEffect, useContext } from "react"
|
||||||
import { steps, stepTypeToStepIndex, stepIndexToStepType } from "../utils/steps"
|
import { steps, stepTypeToStepIndex, stepIndexToStepType } from "../utils/steps"
|
||||||
import { CgCheck } from "react-icons/cg"
|
import { CgCheck } from "react-icons/cg"
|
||||||
|
import { ImportSessionContext } from "@/contexts/ImportSessionContext"
|
||||||
|
|
||||||
const CheckIcon = ({ color }: { color: string }) => <CgCheck size="24" className={color} />
|
const CheckIcon = ({ color }: { color: string }) => <CgCheck size="24" className={color} />
|
||||||
|
|
||||||
export const Steps = () => {
|
export const Steps = () => {
|
||||||
const { initialStepState, translations, isNavigationEnabled, isOpen } = useRsi()
|
const { initialStepState, translations, isNavigationEnabled } = useRsi()
|
||||||
|
const { clearSession } = useContext(ImportSessionContext)
|
||||||
const initialStep = stepTypeToStepIndex(initialStepState?.type)
|
const initialStep = stepTypeToStepIndex(initialStepState?.type)
|
||||||
const [activeStep, setActiveStep] = useState(initialStep)
|
const [activeStep, setActiveStep] = useState(initialStep)
|
||||||
const [state, setState] = useState<StepState>(initialStepState || { type: StepType.upload })
|
const [state, setState] = useState<StepState>(initialStepState || { type: StepType.upload })
|
||||||
const history = useRef<StepState[]>([])
|
const history = useRef<StepState[]>([])
|
||||||
const prevIsOpen = useRef(isOpen)
|
|
||||||
|
|
||||||
// Reset state when dialog is reopened
|
// Clear previous session on mount so each open starts fresh.
|
||||||
|
// Steps unmounts when the dialog closes (Radix portal), so every open = fresh mount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if dialog was closed and is now open again
|
clearSession()
|
||||||
if (isOpen && !prevIsOpen.current) {
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// Reset to initial state
|
}, [])
|
||||||
setActiveStep(initialStep)
|
|
||||||
setState(initialStepState || { type: StepType.upload })
|
|
||||||
history.current = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update previous isOpen value
|
|
||||||
prevIsOpen.current = isOpen
|
|
||||||
}, [isOpen, initialStep, initialStepState])
|
|
||||||
|
|
||||||
const onClickStep = (stepIndex: number) => {
|
const onClickStep = (stepIndex: number) => {
|
||||||
const type = stepIndexToStepType(stepIndex)
|
const type = stepIndexToStepType(stepIndex)
|
||||||
const historyIdx = history.current.findIndex((v) => v.type === type)
|
let historyIdx = history.current.findIndex((v) => v.type === type)
|
||||||
|
|
||||||
|
// Special case: step index 0 could be either upload or selectSheet
|
||||||
|
// If we didn't find upload, also check for selectSheet
|
||||||
|
if (historyIdx === -1 && stepIndex === 0) {
|
||||||
|
historyIdx = history.current.findIndex((v) => v.type === StepType.selectSheet)
|
||||||
|
}
|
||||||
|
|
||||||
if (historyIdx === -1) return
|
if (historyIdx === -1) return
|
||||||
const nextHistory = history.current.slice(0, historyIdx + 1)
|
const nextHistory = history.current.slice(0, historyIdx + 1)
|
||||||
history.current = nextHistory
|
history.current = nextHistory
|
||||||
@@ -39,19 +40,29 @@ export const Steps = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onBack = () => {
|
const onBack = () => {
|
||||||
onClickStep(Math.max(activeStep - 1, 0))
|
// For back navigation, we want to go to the previous entry in history
|
||||||
|
// rather than relying on step index, since selectSheet shares index with upload
|
||||||
|
if (history.current.length > 0) {
|
||||||
|
const previousState = history.current[history.current.length - 1]
|
||||||
|
history.current = history.current.slice(0, -1)
|
||||||
|
setState(previousState)
|
||||||
|
setActiveStep(stepTypeToStepIndex(previousState.type))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onNext = (v: StepState) => {
|
const onNext = (v: StepState) => {
|
||||||
history.current.push(state)
|
history.current.push(state)
|
||||||
setState(v)
|
setState(v)
|
||||||
|
|
||||||
if (v.type === StepType.validateData && 'isFromScratch' in v && v.isFromScratch) {
|
if (v.type === StepType.validateData && 'isFromScratch' in v && v.isFromScratch) {
|
||||||
// If starting from scratch, jump directly to the validation step
|
// If starting from scratch, jump directly to the validation step
|
||||||
const validationStepIndex = steps.indexOf('validationStep')
|
const validationStepIndex = steps.indexOf('validationStep')
|
||||||
setActiveStep(validationStepIndex)
|
setActiveStep(validationStepIndex)
|
||||||
} else if (v.type !== StepType.selectSheet) {
|
} else if (v.type !== StepType.selectSheet) {
|
||||||
setActiveStep(activeStep + 1)
|
// Use the step type to determine the correct index directly,
|
||||||
|
// rather than incrementing, to avoid stale closure issues
|
||||||
|
const targetIndex = stepTypeToStepIndex(v.type)
|
||||||
|
setActiveStep(targetIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { Progress } from "@/components/ui/progress"
|
|||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
||||||
import { useValidationStore } from "./ValidationStep/store/validationStore"
|
import { useValidationStore } from "./ValidationStep/store/validationStore"
|
||||||
|
import { useImportSession } from "@/contexts/ImportSessionContext"
|
||||||
|
import type { ImportSession } from "@/types/importSession"
|
||||||
|
|
||||||
export enum StepType {
|
export enum StepType {
|
||||||
upload = "upload",
|
upload = "upload",
|
||||||
@@ -123,10 +125,46 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
// Keep track of global selections across steps
|
// Keep track of global selections across steps
|
||||||
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
|
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
|
||||||
state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns
|
state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns
|
||||||
? state.globalSelections
|
? state.globalSelections
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Import session context for session restoration
|
||||||
|
const { loadSession, setGlobalSelections: setSessionGlobalSelections } = useImportSession()
|
||||||
|
|
||||||
|
// Sync global selections to session context for autosave
|
||||||
|
useEffect(() => {
|
||||||
|
setSessionGlobalSelections(persistedGlobalSelections)
|
||||||
|
}, [persistedGlobalSelections, setSessionGlobalSelections])
|
||||||
|
|
||||||
|
// Handle restoring a saved session
|
||||||
|
const handleRestoreSession = useCallback((session: ImportSession) => {
|
||||||
|
// Load the session into context
|
||||||
|
loadSession(session)
|
||||||
|
|
||||||
|
// Update global selections if they exist
|
||||||
|
if (session.global_selections) {
|
||||||
|
setPersistedGlobalSelections(session.global_selections)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the appropriate step with session data
|
||||||
|
if (session.current_step === 'imageUpload') {
|
||||||
|
onNext({
|
||||||
|
type: StepType.imageUpload,
|
||||||
|
data: session.data,
|
||||||
|
file: new File([], "restored-session.xlsx"),
|
||||||
|
globalSelections: session.global_selections || undefined,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Default to validation step
|
||||||
|
onNext({
|
||||||
|
type: StepType.validateDataNew,
|
||||||
|
data: session.data,
|
||||||
|
globalSelections: session.global_selections || undefined,
|
||||||
|
isFromScratch: true, // Treat restored sessions like "from scratch" for back navigation
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [loadSession, onNext])
|
||||||
|
|
||||||
switch (state.type) {
|
switch (state.type) {
|
||||||
case StepType.upload:
|
case StepType.upload:
|
||||||
@@ -164,6 +202,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onRestoreSession={handleRestoreSession}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case StepType.selectSheet:
|
case StepType.selectSheet:
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
import type XLSX from "xlsx"
|
import type XLSX from "xlsx"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState, useContext } from "react"
|
||||||
import { useRsi } from "../../hooks/useRsi"
|
import { useRsi } from "../../hooks/useRsi"
|
||||||
import { DropZone } from "./components/DropZone"
|
import { DropZone } from "./components/DropZone"
|
||||||
import { StepType } from "../UploadFlow"
|
import { StepType } from "../UploadFlow"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Bug } from "lucide-react"
|
||||||
|
import { v4 as uuidv4 } from "uuid"
|
||||||
|
import { SavedSessionsList } from "../../components/SavedSessionsList"
|
||||||
|
import type { ImportSession } from "@/types/importSession"
|
||||||
|
|
||||||
type UploadProps = {
|
type UploadProps = {
|
||||||
onContinue: (data: XLSX.WorkBook, file: File) => Promise<void>
|
onContinue: (data: XLSX.WorkBook, file: File) => Promise<void>
|
||||||
setInitialState?: (state: { type: StepType; data: any[]; isFromScratch?: boolean }) => void
|
setInitialState?: (state: { type: StepType; data: any[]; isFromScratch?: boolean }) => void
|
||||||
|
onRestoreSession?: (session: ImportSession) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: UploadProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { translations } = useRsi()
|
const { translations } = useRsi()
|
||||||
|
const { user } = useContext(AuthContext)
|
||||||
|
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"))
|
||||||
|
|
||||||
|
// Debug import state
|
||||||
|
const [debugDialogOpen, setDebugDialogOpen] = useState(false)
|
||||||
|
const [debugJsonInput, setDebugJsonInput] = useState("")
|
||||||
|
const [debugError, setDebugError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleOnContinue = useCallback(
|
const handleOnContinue = useCallback(
|
||||||
async (data: XLSX.WorkBook, file: File) => {
|
async (data: XLSX.WorkBook, file: File) => {
|
||||||
@@ -29,11 +52,63 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
|||||||
setInitialState({ type: StepType.validateData, data: [{}], isFromScratch: true })
|
setInitialState({ type: StepType.validateData, data: [{}], isFromScratch: true })
|
||||||
}
|
}
|
||||||
}, [setInitialState])
|
}, [setInitialState])
|
||||||
|
|
||||||
|
const handleDebugImport = useCallback(() => {
|
||||||
|
setDebugError(null)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(debugJsonInput)
|
||||||
|
|
||||||
|
// Handle both array and object with products property
|
||||||
|
let products: any[] = Array.isArray(parsed) ? parsed : parsed.products
|
||||||
|
|
||||||
|
if (!Array.isArray(products) || products.length === 0) {
|
||||||
|
setDebugError("JSON must be an array of products or an object with a 'products' array")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add __index to each row if not present (required for validation step)
|
||||||
|
const dataWithIndex = products.map((row: any) => ({
|
||||||
|
...row,
|
||||||
|
__index: row.__index || uuidv4()
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (setInitialState) {
|
||||||
|
setInitialState({
|
||||||
|
type: StepType.validateData,
|
||||||
|
data: dataWithIndex,
|
||||||
|
isFromScratch: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setDebugDialogOpen(false)
|
||||||
|
setDebugJsonInput("")
|
||||||
|
} catch (e) {
|
||||||
|
setDebugError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`)
|
||||||
|
}
|
||||||
|
}, [debugJsonInput, setInitialState])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2>
|
<h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2>
|
||||||
|
{hasDebugPermission && (
|
||||||
|
<>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => setDebugDialogOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
className="min-w-[200px] text-amber-600 border-amber-600 hover:bg-amber-50"
|
||||||
|
disabled={!setInitialState}
|
||||||
|
>
|
||||||
|
<Bug className="mr-2 h-4 w-4" />
|
||||||
|
Import JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="max-w-xl mx-auto w-full space-y-8">
|
<div className="max-w-xl mx-auto w-full space-y-8">
|
||||||
<div className="rounded-lg p-6 flex flex-col items-center">
|
<div className="rounded-lg p-6 flex flex-col items-center">
|
||||||
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
|
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
|
||||||
@@ -45,8 +120,8 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
|||||||
<Separator className="w-24" />
|
<Separator className="w-24" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center pb-8">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStartFromScratch}
|
onClick={handleStartFromScratch}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="min-w-[200px]"
|
className="min-w-[200px]"
|
||||||
@@ -55,7 +130,56 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
|||||||
Start from scratch
|
Start from scratch
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Saved sessions list */}
|
||||||
|
{onRestoreSession && (
|
||||||
|
<SavedSessionsList onRestore={onRestoreSession} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={debugDialogOpen} onOpenChange={setDebugDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-amber-600">
|
||||||
|
<Bug className="h-5 w-5" />
|
||||||
|
Debug: Import JSON Data
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="debug-json">Product JSON</Label>
|
||||||
|
<Textarea
|
||||||
|
id="debug-json"
|
||||||
|
placeholder='[{"supplier": "...", "company": "...", "name": "...", "product_images": "url1,url2", ...}]'
|
||||||
|
value={debugJsonInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDebugJsonInput(e.target.value)
|
||||||
|
setDebugError(null)
|
||||||
|
}}
|
||||||
|
className="min-h-[300px] font-mono text-sm"
|
||||||
|
/>
|
||||||
|
{debugError && (
|
||||||
|
<p className="text-sm text-destructive">{debugError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDebugDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDebugImport}
|
||||||
|
disabled={!debugJsonInput.trim()}
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
Import & Go to Validation
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Note: Initialization effects are in index.tsx so they run before this mounts.
|
* Note: Initialization effects are in index.tsx so they run before this mounts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
|
||||||
import { useValidationStore } from '../store/validationStore';
|
import { useValidationStore } from '../store/validationStore';
|
||||||
import {
|
import {
|
||||||
useTotalErrorCount,
|
useTotalErrorCount,
|
||||||
@@ -29,8 +29,11 @@ import { AiDebugDialog } from '../dialogs/AiDebugDialog';
|
|||||||
import { SanityCheckDialog } from '../dialogs/SanityCheckDialog';
|
import { SanityCheckDialog } from '../dialogs/SanityCheckDialog';
|
||||||
import { TemplateForm } from '@/components/templates/TemplateForm';
|
import { TemplateForm } from '@/components/templates/TemplateForm';
|
||||||
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
|
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
|
||||||
|
import { useImportAutosave } from '@/hooks/useImportAutosave';
|
||||||
|
import { useImportSession } from '@/contexts/ImportSessionContext';
|
||||||
import type { CleanRowData, RowData } from '../store/types';
|
import type { CleanRowData, RowData } from '../store/types';
|
||||||
import type { ProductForSanityCheck } from '../hooks/useSanityCheck';
|
import type { ProductForSanityCheck } from '../hooks/useSanityCheck';
|
||||||
|
import type { ImportSessionData, SerializedValidationState } from '@/types/importSession';
|
||||||
|
|
||||||
interface ValidationContainerProps {
|
interface ValidationContainerProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@@ -71,6 +74,50 @@ export const ValidationContainer = ({
|
|||||||
// Handle UPC validation after copy-down operations on supplier/upc fields
|
// Handle UPC validation after copy-down operations on supplier/upc fields
|
||||||
useCopyDownValidation();
|
useCopyDownValidation();
|
||||||
|
|
||||||
|
// Import session context for global selections
|
||||||
|
const { getGlobalSelections } = useImportSession();
|
||||||
|
|
||||||
|
// Import session autosave
|
||||||
|
const { markDirty } = useImportAutosave({
|
||||||
|
enabled: true,
|
||||||
|
step: 'validation',
|
||||||
|
getSessionData: useCallback((): ImportSessionData => {
|
||||||
|
const state = useValidationStore.getState();
|
||||||
|
|
||||||
|
// Serialize Maps to plain objects for JSON storage
|
||||||
|
const serializedValidationState: SerializedValidationState = {
|
||||||
|
errors: Object.fromEntries(
|
||||||
|
Array.from(state.errors.entries()).map(([k, v]) => [k, v])
|
||||||
|
),
|
||||||
|
upcStatus: Object.fromEntries(
|
||||||
|
Array.from(state.upcStatus.entries()).map(([k, v]) => [k, v])
|
||||||
|
),
|
||||||
|
generatedItemNumbers: Object.fromEntries(
|
||||||
|
Array.from(state.generatedItemNumbers.entries()).map(([k, v]) => [k, v])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
current_step: 'validation',
|
||||||
|
data: state.rows,
|
||||||
|
global_selections: getGlobalSelections(),
|
||||||
|
validation_state: serializedValidationState,
|
||||||
|
};
|
||||||
|
}, [getGlobalSelections]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to store changes to trigger autosave
|
||||||
|
useEffect(() => {
|
||||||
|
// Subscribe to row changes to mark session as dirty
|
||||||
|
const unsubscribe = useValidationStore.subscribe(
|
||||||
|
(state) => state.rows,
|
||||||
|
() => {
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [markDirty]);
|
||||||
|
|
||||||
// Get initial products for AI suggestions (read once via ref to avoid re-fetching)
|
// Get initial products for AI suggestions (read once via ref to avoid re-fetching)
|
||||||
const initialProductsRef = useRef<RowData[] | null>(null);
|
const initialProductsRef = useRef<RowData[] | null>(null);
|
||||||
if (initialProductsRef.current === null) {
|
if (initialProductsRef.current === null) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
|
|||||||
import { type ColumnDef } from '@tanstack/react-table';
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { ArrowDown, Wand2, Loader2, Calculator, Scale } from 'lucide-react';
|
import { ArrowDown, Wand2, Loader2, Calculator, Scale, Pin, PinOff } from 'lucide-react';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
@@ -27,6 +27,7 @@ import { useValidationStore } from '../store/validationStore';
|
|||||||
import {
|
import {
|
||||||
useFields,
|
useFields,
|
||||||
useFilters,
|
useFilters,
|
||||||
|
useRowCount,
|
||||||
} from '../store/selectors';
|
} from '../store/selectors';
|
||||||
// NOTE: We intentionally do NOT import useValidationActions or useProductLines here!
|
// NOTE: We intentionally do NOT import useValidationActions or useProductLines here!
|
||||||
// Those hooks subscribe to global state (rows, errors, caches) which would cause
|
// Those hooks subscribe to global state (rows, errors, caches) which would cause
|
||||||
@@ -1210,8 +1211,10 @@ interface VirtualRowProps {
|
|||||||
columns: ColumnDef<RowData>[];
|
columns: ColumnDef<RowData>[];
|
||||||
fields: Field<string>[];
|
fields: Field<string>[];
|
||||||
totalRowCount: number;
|
totalRowCount: number;
|
||||||
/** Whether table is scrolled horizontally - used for sticky column shadow */
|
/** Whether the name column sticky behavior is enabled */
|
||||||
isScrolledHorizontally: boolean;
|
nameColumnSticky: boolean;
|
||||||
|
/** Direction for sticky name column: 'left', 'right', or null (not sticky) */
|
||||||
|
stickyDirection: 'left' | 'right' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtualRow = memo(({
|
const VirtualRow = memo(({
|
||||||
@@ -1221,7 +1224,8 @@ const VirtualRow = memo(({
|
|||||||
columns,
|
columns,
|
||||||
fields,
|
fields,
|
||||||
totalRowCount,
|
totalRowCount,
|
||||||
isScrolledHorizontally,
|
nameColumnSticky,
|
||||||
|
stickyDirection,
|
||||||
}: VirtualRowProps) => {
|
}: VirtualRowProps) => {
|
||||||
// Subscribe to row data - this is THE subscription for all cell values in this row
|
// Subscribe to row data - this is THE subscription for all cell values in this row
|
||||||
const rowData = useValidationStore(
|
const rowData = useValidationStore(
|
||||||
@@ -1317,13 +1321,18 @@ const VirtualRow = memo(({
|
|||||||
<div
|
<div
|
||||||
data-row-index={rowIndex}
|
data-row-index={rowIndex}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex border-b absolute',
|
'flex absolute',
|
||||||
|
// Use box-shadow for bottom border - renders more consistently with transforms than border-b
|
||||||
|
'shadow-[inset_0_-1px_0_0_hsl(var(--border))]',
|
||||||
hasErrors && 'bg-destructive/5',
|
hasErrors && 'bg-destructive/5',
|
||||||
isSelected && 'bg-primary/5'
|
isSelected && 'bg-blue-100 dark:bg-blue-900/40'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
height: ROW_HEIGHT,
|
height: ROW_HEIGHT,
|
||||||
transform: `translateY(${virtualStart}px)`,
|
// Round to whole pixels to prevent sub-pixel rendering issues during scroll
|
||||||
|
transform: `translateY(${Math.round(virtualStart)}px)`,
|
||||||
|
// Promote to GPU layer for smoother rendering
|
||||||
|
willChange: 'transform',
|
||||||
// Elevate row when it has a visible AI suggestion so badge shows above next row
|
// Elevate row when it has a visible AI suggestion so badge shows above next row
|
||||||
zIndex: hasVisibleAiSuggestion ? 10 : undefined,
|
zIndex: hasVisibleAiSuggestion ? 10 : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -1331,7 +1340,7 @@ const VirtualRow = memo(({
|
|||||||
>
|
>
|
||||||
{/* Selection checkbox cell */}
|
{/* Selection checkbox cell */}
|
||||||
<div
|
<div
|
||||||
className="px-2 py-3 border-r flex items-start justify-center"
|
className="px-2 py-3 flex items-start justify-center shadow-[inset_-1px_0_0_0_hsl(var(--border))]"
|
||||||
style={{
|
style={{
|
||||||
width: columns[0]?.size || 40,
|
width: columns[0]?.size || 40,
|
||||||
minWidth: columns[0]?.size || 40,
|
minWidth: columns[0]?.size || 40,
|
||||||
@@ -1346,7 +1355,7 @@ const VirtualRow = memo(({
|
|||||||
|
|
||||||
{/* Template column */}
|
{/* Template column */}
|
||||||
<div
|
<div
|
||||||
className="px-2 py-2 border-r flex items-start overflow-hidden"
|
className="px-2 py-2 flex items-start overflow-hidden shadow-[inset_-1px_0_0_0_hsl(var(--border))]"
|
||||||
style={{
|
style={{
|
||||||
width: TEMPLATE_COLUMN_WIDTH,
|
width: TEMPLATE_COLUMN_WIDTH,
|
||||||
minWidth: TEMPLATE_COLUMN_WIDTH,
|
minWidth: TEMPLATE_COLUMN_WIDTH,
|
||||||
@@ -1400,33 +1409,50 @@ const VirtualRow = memo(({
|
|||||||
|
|
||||||
const isNameColumn = field.key === 'name';
|
const isNameColumn = field.key === 'name';
|
||||||
|
|
||||||
|
// Determine sticky behavior for name column
|
||||||
|
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
|
||||||
|
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
|
||||||
|
const stickyRight = shouldBeSticky && stickyDirection === 'right';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={field.key}
|
key={field.key}
|
||||||
data-cell-field={field.key}
|
data-cell-field={field.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-2 border-r last:border-r-0 flex items-start",
|
"px-2 py-2 flex items-start",
|
||||||
|
// Use box-shadow for right border - renders more consistently with transforms
|
||||||
|
// last:shadow-none removes the shadow from the last cell
|
||||||
|
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
||||||
// Name column needs overflow-visible for the floating AI suggestion badge
|
// Name column needs overflow-visible for the floating AI suggestion badge
|
||||||
// Description handles AI suggestions inside its popover, so no overflow needed
|
// Description handles AI suggestions inside its popover, so no overflow needed
|
||||||
isNameColumn ? "overflow-visible" : "overflow-hidden",
|
isNameColumn ? "overflow-visible" : "overflow-hidden",
|
||||||
// Name column is sticky - needs SOLID (opaque) background that matches row state
|
// Name column sticky behavior - only when enabled and scrolled appropriately
|
||||||
// Uses gradient trick to composite semi-transparent tint onto solid background
|
shouldBeSticky && "lg:sticky lg:z-10",
|
||||||
// Shadow only shows when scrolled horizontally (column is actually overlaying content)
|
// Add left border when sticky-right since content scrolls behind from the left
|
||||||
isNameColumn && "lg:sticky lg:z-10",
|
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border))]",
|
||||||
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
|
// Directional drop shadow on the outside edge where content scrolls behind (combined with border shadow)
|
||||||
isNameColumn && (
|
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||||
hasErrors
|
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||||
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
|
// Solid background when sticky to overlay content
|
||||||
: isSelected
|
// Use explicit [background:] syntax for consistent specificity
|
||||||
? "lg:[background:linear-gradient(hsl(var(--primary)/0.05),hsl(var(--primary)/0.05)),hsl(var(--background))]"
|
// Selection (blue) takes priority over errors (red)
|
||||||
: "lg:bg-background"
|
shouldBeSticky && (
|
||||||
)
|
isSelected
|
||||||
|
? "lg:[background:#dbeafe] lg:dark:[background:hsl(221_83%_25%/0.4)]"
|
||||||
|
: hasErrors
|
||||||
|
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
|
||||||
|
: "lg:[background:hsl(var(--background))]"
|
||||||
|
),
|
||||||
|
// Make child inputs/buttons transparent when sticky + selected so blue background shows through
|
||||||
|
shouldBeSticky && isSelected && "lg:[&_input]:!bg-transparent lg:[&_button]:!bg-transparent lg:[&_textarea]:!bg-transparent"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: columnWidth,
|
width: columnWidth,
|
||||||
minWidth: columnWidth,
|
minWidth: columnWidth,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
...(isNameColumn && { left: NAME_COLUMN_STICKY_LEFT }),
|
// Position sticky left or right based on scroll direction
|
||||||
|
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
|
||||||
|
...(stickyRight && { right: 0 }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CellWrapper
|
<CellWrapper
|
||||||
@@ -1462,27 +1488,79 @@ VirtualRow.displayName = 'VirtualRow';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Header checkbox component - isolated to prevent re-renders of the entire table
|
* Header checkbox component - isolated to prevent re-renders of the entire table
|
||||||
|
* When filtering is active, only selects/deselects visible (filtered) rows
|
||||||
*/
|
*/
|
||||||
const HeaderCheckbox = memo(() => {
|
const HeaderCheckbox = memo(() => {
|
||||||
const rowCount = useValidationStore((state) => state.rows.length);
|
const filters = useFilters();
|
||||||
const selectedCount = useValidationStore((state) => state.selectedRows.size);
|
// Subscribe to row count to recalculate when rows are added/removed
|
||||||
|
const rowCount = useRowCount();
|
||||||
|
|
||||||
const allSelected = rowCount > 0 && selectedCount === rowCount;
|
// Compute which rows are visible based on current filters
|
||||||
const someSelected = selectedCount > 0 && selectedCount < rowCount;
|
const { visibleRowIds, visibleCount } = useMemo(() => {
|
||||||
|
const { rows, errors } = useValidationStore.getState();
|
||||||
|
const isFiltering = filters.searchText || filters.showErrorsOnly;
|
||||||
|
|
||||||
|
if (!isFiltering) {
|
||||||
|
// No filtering - all rows are visible
|
||||||
|
const ids = new Set(rows.map((row) => row.__index));
|
||||||
|
return { visibleRowIds: ids, visibleCount: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters to get visible row IDs
|
||||||
|
const ids = new Set<string>();
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
// Apply search filter
|
||||||
|
if (filters.searchText) {
|
||||||
|
const searchLower = filters.searchText.toLowerCase();
|
||||||
|
const matches = Object.values(row).some((value) =>
|
||||||
|
String(value ?? '').toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
if (!matches) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply errors-only filter
|
||||||
|
if (filters.showErrorsOnly) {
|
||||||
|
const rowErrors = errors.get(index);
|
||||||
|
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.add(row.__index);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { visibleRowIds: ids, visibleCount: ids.size };
|
||||||
|
}, [filters.searchText, filters.showErrorsOnly, rowCount]);
|
||||||
|
|
||||||
|
// Check selection state against visible rows only
|
||||||
|
const selectedRows = useValidationStore((state) => state.selectedRows);
|
||||||
|
const selectedVisibleCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
visibleRowIds.forEach((id) => {
|
||||||
|
if (selectedRows.has(id)) count++;
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}, [visibleRowIds, selectedRows]);
|
||||||
|
|
||||||
|
const allVisibleSelected = visibleCount > 0 && selectedVisibleCount === visibleCount;
|
||||||
|
const someVisibleSelected = selectedVisibleCount > 0 && selectedVisibleCount < visibleCount;
|
||||||
|
|
||||||
const handleChange = useCallback((value: boolean | 'indeterminate') => {
|
const handleChange = useCallback((value: boolean | 'indeterminate') => {
|
||||||
const { setSelectedRows, rows } = useValidationStore.getState();
|
const { setSelectedRows, selectedRows: currentSelected } = useValidationStore.getState();
|
||||||
if (value) {
|
if (value) {
|
||||||
const allIds = new Set(rows.map((row) => row.__index));
|
// Add all visible rows to selection (keep existing selections of non-visible rows)
|
||||||
setSelectedRows(allIds);
|
const newSelection = new Set(currentSelected);
|
||||||
|
visibleRowIds.forEach((id) => newSelection.add(id));
|
||||||
|
setSelectedRows(newSelection);
|
||||||
} else {
|
} else {
|
||||||
setSelectedRows(new Set());
|
// Remove all visible rows from selection (keep selections of non-visible rows)
|
||||||
|
const newSelection = new Set(currentSelected);
|
||||||
|
visibleRowIds.forEach((id) => newSelection.delete(id));
|
||||||
|
setSelectedRows(newSelection);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [visibleRowIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allSelected || (someSelected && 'indeterminate')}
|
checked={allVisibleSelected || (someVisibleSelected && 'indeterminate')}
|
||||||
onCheckedChange={handleChange}
|
onCheckedChange={handleChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1536,20 +1614,19 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
: 'Fill empty cells with MSRP ÷ 2';
|
: 'Fill empty cells with MSRP ÷ 2';
|
||||||
|
|
||||||
// Check if there are any cells that can be filled (called on hover)
|
// Check if there are any cells that can be filled (called on hover)
|
||||||
|
// Now returns true if ANY row has a valid source value (allows overwriting existing values)
|
||||||
const checkFillableCells = useCallback(() => {
|
const checkFillableCells = useCallback(() => {
|
||||||
const { rows } = useValidationStore.getState();
|
const { rows } = useValidationStore.getState();
|
||||||
return rows.some((row) => {
|
return rows.some((row) => {
|
||||||
const currentValue = row[fieldKey];
|
|
||||||
const sourceValue = row[sourceField];
|
const sourceValue = row[sourceField];
|
||||||
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
|
|
||||||
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
||||||
if (isEmpty && hasSource) {
|
if (hasSource) {
|
||||||
const sourceNum = parseFloat(String(sourceValue));
|
const sourceNum = parseFloat(String(sourceValue));
|
||||||
return !isNaN(sourceNum) && sourceNum > 0;
|
return !isNaN(sourceNum) && sourceNum > 0;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}, [fieldKey, sourceField]);
|
}, [sourceField]);
|
||||||
|
|
||||||
// Update fillable check on hover
|
// Update fillable check on hover
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
@@ -1563,29 +1640,28 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
// Use setState() for efficient batch update with Immer
|
// Use setState() for efficient batch update with Immer
|
||||||
useValidationStore.setState((draft) => {
|
useValidationStore.setState((draft) => {
|
||||||
draft.rows.forEach((row, index) => {
|
draft.rows.forEach((row, index) => {
|
||||||
const currentValue = row[fieldKey];
|
|
||||||
const sourceValue = row[sourceField];
|
const sourceValue = row[sourceField];
|
||||||
|
|
||||||
// Only fill if current field is empty and source has a value
|
// Fill if source has a value (overwrite existing values based on source)
|
||||||
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
|
|
||||||
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
||||||
|
|
||||||
if (isEmpty && hasSource) {
|
if (hasSource) {
|
||||||
const sourceNum = parseFloat(String(sourceValue));
|
const sourceNum = parseFloat(String(sourceValue));
|
||||||
if (!isNaN(sourceNum) && sourceNum > 0) {
|
if (!isNaN(sourceNum) && sourceNum > 0) {
|
||||||
let msrp = sourceNum * multiplier;
|
let msrp = sourceNum * multiplier;
|
||||||
|
|
||||||
if (multiplier === 2.0) {
|
if (multiplier === 2.0) {
|
||||||
// For 2x: auto-adjust by ±1 cent to get to .99 if close
|
// For 2x: auto-adjust by ±1 cent ONLY if result ends in .99
|
||||||
const cents = Math.round((msrp % 1) * 100);
|
const cents = Math.round((msrp % 1) * 100);
|
||||||
if (cents === 0) {
|
if (cents === 0 || cents === 98) {
|
||||||
// .00 → subtract 1 cent to get .99
|
const adjustment = cents === 0 ? -0.01 : 0.01;
|
||||||
msrp -= 0.01;
|
const adjusted = (msrp + adjustment).toFixed(2);
|
||||||
} else if (cents === 98) {
|
// Only apply if the adjusted value actually ends in .99
|
||||||
// .98 → add 1 cent to get .99
|
if (adjusted.endsWith('.99')) {
|
||||||
msrp += 0.01;
|
msrp = parseFloat(adjusted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Otherwise leave as-is
|
// Otherwise leave as-is (exact 2x)
|
||||||
} else if (roundNine) {
|
} else if (roundNine) {
|
||||||
// For >2x with checkbox: round to nearest .X9
|
// For >2x with checkbox: round to nearest .X9
|
||||||
msrp = roundToNine(msrp);
|
msrp = roundToNine(msrp);
|
||||||
@@ -1616,13 +1692,12 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
|
|
||||||
useValidationStore.setState((draft) => {
|
useValidationStore.setState((draft) => {
|
||||||
draft.rows.forEach((row, index) => {
|
draft.rows.forEach((row, index) => {
|
||||||
const currentValue = row[fieldKey];
|
|
||||||
const sourceValue = row[sourceField];
|
const sourceValue = row[sourceField];
|
||||||
|
|
||||||
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
|
// Fill if source has a value (overwrite existing values based on source)
|
||||||
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
||||||
|
|
||||||
if (isEmpty && hasSource) {
|
if (hasSource) {
|
||||||
const sourceNum = parseFloat(String(sourceValue));
|
const sourceNum = parseFloat(String(sourceValue));
|
||||||
if (!isNaN(sourceNum) && sourceNum > 0) {
|
if (!isNaN(sourceNum) && sourceNum > 0) {
|
||||||
draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2);
|
draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2);
|
||||||
@@ -1928,6 +2003,165 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
|||||||
|
|
||||||
UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader';
|
UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DefaultValueColumnHeader Component
|
||||||
|
*
|
||||||
|
* Renders a column header with a hover button that sets a default value for all rows.
|
||||||
|
* Used for Tax Category ("Not Specifically Set") and Shipping Restrictions ("None").
|
||||||
|
*
|
||||||
|
* PERFORMANCE: Uses local hover state and getState() for bulk updates.
|
||||||
|
*/
|
||||||
|
interface DefaultValueColumnHeaderProps {
|
||||||
|
fieldKey: 'tax_cat' | 'ship_restrictions';
|
||||||
|
label: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VALUE_CONFIG: Record<string, { value: string; displayName: string; buttonLabel: string }> = {
|
||||||
|
tax_cat: { value: '0', displayName: 'Not Specifically Set', buttonLabel: 'Set All Default' },
|
||||||
|
ship_restrictions: { value: '0', displayName: 'None', buttonLabel: 'Set All None' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultValueColumnHeaderProps) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [hasEmptyCells, setHasEmptyCells] = useState(false);
|
||||||
|
|
||||||
|
const config = DEFAULT_VALUE_CONFIG[fieldKey];
|
||||||
|
|
||||||
|
// Check if there are any empty cells that can be filled
|
||||||
|
const checkEmptyCells = useCallback(() => {
|
||||||
|
const { rows } = useValidationStore.getState();
|
||||||
|
return rows.some((row) => {
|
||||||
|
const value = row[fieldKey];
|
||||||
|
return value === undefined || value === null || value === '';
|
||||||
|
});
|
||||||
|
}, [fieldKey]);
|
||||||
|
|
||||||
|
// Update empty check on hover
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
setIsHovered(true);
|
||||||
|
setHasEmptyCells(checkEmptyCells());
|
||||||
|
}, [checkEmptyCells]);
|
||||||
|
|
||||||
|
const handleSetDefault = useCallback(() => {
|
||||||
|
const updatedIndices: number[] = [];
|
||||||
|
|
||||||
|
useValidationStore.setState((draft) => {
|
||||||
|
draft.rows.forEach((row, index) => {
|
||||||
|
const value = row[fieldKey];
|
||||||
|
const isEmpty = value === undefined || value === null || value === '';
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
draft.rows[index][fieldKey] = config.value;
|
||||||
|
updatedIndices.push(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedIndices.length > 0) {
|
||||||
|
const { clearFieldError } = useValidationStore.getState();
|
||||||
|
updatedIndices.forEach((rowIndex) => {
|
||||||
|
clearFieldError(rowIndex, fieldKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`Set ${updatedIndices.length} row${updatedIndices.length === 1 ? '' : 's'} to "${config.displayName}"`);
|
||||||
|
}
|
||||||
|
}, [fieldKey, config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 truncate w-full group relative"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{isRequired && (
|
||||||
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
|
)}
|
||||||
|
{isHovered && hasEmptyCells && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSetDefault();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||||
|
'flex items-center gap-0.5',
|
||||||
|
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||||
|
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
'transition-opacity whitespace-nowrap'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Wand2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>Fill empty cells with "{config.displayName}"</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DefaultValueColumnHeader.displayName = 'DefaultValueColumnHeader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NameColumnHeader Component
|
||||||
|
*
|
||||||
|
* Renders the Name column header with a sticky toggle button.
|
||||||
|
* Pin icon toggles whether the name column sticks to edges when scrolling.
|
||||||
|
*/
|
||||||
|
interface NameColumnHeaderProps {
|
||||||
|
label: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
isSticky: boolean;
|
||||||
|
onToggleSticky: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NameColumnHeader = memo(({ label, isRequired, isSticky, onToggleSticky }: NameColumnHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 truncate w-full group relative">
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{isRequired && (
|
||||||
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
|
)}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleSticky();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'ml-auto flex items-center justify-center w-6 h-6 rounded',
|
||||||
|
'transition-colors',
|
||||||
|
isSticky
|
||||||
|
? 'text-primary bg-primary/10 hover:bg-primary/20'
|
||||||
|
: 'text-muted-foreground hover:bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSticky ? <Pin className="h-3.5 w-3.5" /> : <PinOff className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>{isSticky ? 'Unpin column' : 'Pin column'}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
NameColumnHeader.displayName = 'NameColumnHeader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main table component
|
* Main table component
|
||||||
*
|
*
|
||||||
@@ -1958,18 +2192,46 @@ export const ValidationTable = () => {
|
|||||||
return offset;
|
return offset;
|
||||||
}, [fields]);
|
}, [fields]);
|
||||||
|
|
||||||
// Track horizontal scroll for sticky column shadow
|
// Name column sticky toggle - when enabled, column sticks to left/right edges based on scroll
|
||||||
const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false);
|
const [nameColumnSticky, setNameColumnSticky] = useState(true);
|
||||||
|
|
||||||
// Sync header scroll with body scroll + track horizontal scroll state
|
// Track scroll direction relative to name column: 'left' (stick to left) or 'right' (stick to right)
|
||||||
|
const [stickyDirection, setStickyDirection] = useState<'left' | 'right' | null>(null);
|
||||||
|
|
||||||
|
// Calculate name column width
|
||||||
|
const nameColumnWidth = useMemo(() => {
|
||||||
|
const nameField = fields.find(f => f.key === 'name');
|
||||||
|
return nameField?.width || 400;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Sync header scroll with body scroll + track sticky direction
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (tableContainerRef.current && headerRef.current) {
|
if (tableContainerRef.current && headerRef.current) {
|
||||||
const scrollLeft = tableContainerRef.current.scrollLeft;
|
const scrollLeft = tableContainerRef.current.scrollLeft;
|
||||||
|
const viewportWidth = tableContainerRef.current.clientWidth;
|
||||||
headerRef.current.scrollLeft = scrollLeft;
|
headerRef.current.scrollLeft = scrollLeft;
|
||||||
// Only show shadow when scrolled past the name column's natural position
|
|
||||||
setIsScrolledHorizontally(scrollLeft > nameColumnLeftOffset);
|
// Calculate name column's position relative to viewport
|
||||||
|
const namePositionInViewport = nameColumnLeftOffset - scrollLeft;
|
||||||
|
const nameRightEdge = namePositionInViewport + nameColumnWidth;
|
||||||
|
|
||||||
|
// Determine sticky direction for name column
|
||||||
|
if (nameColumnSticky) {
|
||||||
|
if (scrollLeft > nameColumnLeftOffset) {
|
||||||
|
// Scrolled right past name column - stick to left
|
||||||
|
setStickyDirection('left');
|
||||||
|
} else if (nameRightEdge > viewportWidth) {
|
||||||
|
// Name column extends beyond viewport to the right - stick to right
|
||||||
|
setStickyDirection('right');
|
||||||
|
} else {
|
||||||
|
// Name column is fully visible - no sticky needed
|
||||||
|
setStickyDirection(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setStickyDirection(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [nameColumnLeftOffset]);
|
}, [nameColumnLeftOffset, nameColumnWidth, nameColumnSticky]);
|
||||||
|
|
||||||
// Compute filtered indices AND row IDs in a single pass
|
// Compute filtered indices AND row IDs in a single pass
|
||||||
// This avoids calling getState() during render for each row
|
// This avoids calling getState() during render for each row
|
||||||
@@ -2012,6 +2274,11 @@ export const ValidationTable = () => {
|
|||||||
return { filteredIndices: indices, rowIdMap: idMap };
|
return { filteredIndices: indices, rowIdMap: idMap };
|
||||||
}, [rowCount, filters.searchText, filters.showErrorsOnly]);
|
}, [rowCount, filters.searchText, filters.showErrorsOnly]);
|
||||||
|
|
||||||
|
// Toggle for sticky name column
|
||||||
|
const toggleNameColumnSticky = useCallback(() => {
|
||||||
|
setNameColumnSticky(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Build columns - ONLY depends on fields, NOT selection state
|
// Build columns - ONLY depends on fields, NOT selection state
|
||||||
// Selection state is handled by isolated HeaderCheckbox component
|
// Selection state is handled by isolated HeaderCheckbox component
|
||||||
const columns = useMemo<ColumnDef<RowData>[]>(() => {
|
const columns = useMemo<ColumnDef<RowData>[]>(() => {
|
||||||
@@ -2034,9 +2301,21 @@ export const ValidationTable = () => {
|
|||||||
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
|
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
|
||||||
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
||||||
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
|
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
|
||||||
|
const isDefaultValueColumn = field.key === 'tax_cat' || field.key === 'ship_restrictions';
|
||||||
|
const isNameColumn = field.key === 'name';
|
||||||
|
|
||||||
// Determine which header component to render
|
// Determine which header component to render
|
||||||
const renderHeader = () => {
|
const renderHeader = () => {
|
||||||
|
if (isNameColumn) {
|
||||||
|
return (
|
||||||
|
<NameColumnHeader
|
||||||
|
label={field.label}
|
||||||
|
isRequired={isRequired}
|
||||||
|
isSticky={nameColumnSticky}
|
||||||
|
onToggleSticky={toggleNameColumnSticky}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (isPriceColumn) {
|
if (isPriceColumn) {
|
||||||
return (
|
return (
|
||||||
<PriceColumnHeader
|
<PriceColumnHeader
|
||||||
@@ -2055,6 +2334,15 @@ export const ValidationTable = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isDefaultValueColumn) {
|
||||||
|
return (
|
||||||
|
<DefaultValueColumnHeader
|
||||||
|
fieldKey={field.key as 'tax_cat' | 'ship_restrictions'}
|
||||||
|
label={field.label}
|
||||||
|
isRequired={isRequired}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 truncate">
|
<div className="flex items-center gap-1 truncate">
|
||||||
<span className="truncate">{field.label}</span>
|
<span className="truncate">{field.label}</span>
|
||||||
@@ -2073,7 +2361,7 @@ export const ValidationTable = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [selectionColumn, templateColumn, ...dataColumns];
|
return [selectionColumn, templateColumn, ...dataColumns];
|
||||||
}, [fields]); // CRITICAL: No selection-related deps!
|
}, [fields, nameColumnSticky, toggleNameColumnSticky]); // Added nameColumnSticky dependencies
|
||||||
|
|
||||||
// Calculate total table width for horizontal scrolling
|
// Calculate total table width for horizontal scrolling
|
||||||
const totalTableWidth = useMemo(() => {
|
const totalTableWidth = useMemo(() => {
|
||||||
@@ -2109,20 +2397,31 @@ export const ValidationTable = () => {
|
|||||||
>
|
>
|
||||||
{columns.map((column, index) => {
|
{columns.map((column, index) => {
|
||||||
const isNameColumn = column.id === 'name';
|
const isNameColumn = column.id === 'name';
|
||||||
|
// Determine sticky behavior for header name column
|
||||||
|
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
|
||||||
|
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
|
||||||
|
const stickyRight = shouldBeSticky && stickyDirection === 'right';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={column.id || index}
|
key={column.id || index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0",
|
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground",
|
||||||
// Sticky header needs solid background matching the row's bg-muted/50 appearance
|
// Use box-shadow for right border - renders more consistently
|
||||||
isNameColumn && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
|
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
||||||
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
|
// Sticky header - only when enabled and scrolled appropriately
|
||||||
|
shouldBeSticky && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
|
||||||
|
// Directional shadow on the outside edge where content scrolls behind (combined with border shadow)
|
||||||
|
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||||
|
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: column.size || 150,
|
width: column.size || 150,
|
||||||
minWidth: column.size || 150,
|
minWidth: column.size || 150,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
...(isNameColumn && { left: NAME_COLUMN_STICKY_LEFT }),
|
// Position sticky left or right based on scroll direction
|
||||||
|
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
|
||||||
|
...(stickyRight && { right: 0 }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{typeof column.header === 'function'
|
{typeof column.header === 'function'
|
||||||
@@ -2159,7 +2458,8 @@ export const ValidationTable = () => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
totalRowCount={rowCount}
|
totalRowCount={rowCount}
|
||||||
isScrolledHorizontally={isScrolledHorizontally}
|
nameColumnSticky={nameColumnSticky}
|
||||||
|
stickyDirection={stickyDirection}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useCallback, useState } from 'react';
|
import { useMemo, useCallback, useState } from 'react';
|
||||||
import { Search, Plus, FolderPlus, Edit3 } from 'lucide-react';
|
import { Search, Plus, FolderPlus, Edit3, X } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '../../../CreateProductCategoryDialog';
|
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '../../../CreateProductCategoryDialog';
|
||||||
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog';
|
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog';
|
||||||
import { useTemplateManagement } from '../hooks/useTemplateManagement';
|
import { useTemplateManagement } from '../hooks/useTemplateManagement';
|
||||||
|
import { SaveSessionButton } from '../../../components/SaveSessionDialog';
|
||||||
|
|
||||||
interface ValidationToolbarProps {
|
interface ValidationToolbarProps {
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
@@ -39,6 +40,38 @@ export const ValidationToolbar = ({
|
|||||||
const filters = useFilters();
|
const filters = useFilters();
|
||||||
const fields = useFields();
|
const fields = useFields();
|
||||||
|
|
||||||
|
// Compute filtered count when filtering is active
|
||||||
|
const filteredCount = useMemo(() => {
|
||||||
|
const isFiltering = filters.searchText || filters.showErrorsOnly;
|
||||||
|
if (!isFiltering) return rowCount;
|
||||||
|
|
||||||
|
const { rows, errors } = useValidationStore.getState();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
// Apply search filter
|
||||||
|
if (filters.searchText) {
|
||||||
|
const searchLower = filters.searchText.toLowerCase();
|
||||||
|
const matches = Object.values(row).some((value) =>
|
||||||
|
String(value ?? '').toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
if (!matches) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply errors-only filter
|
||||||
|
if (filters.showErrorsOnly) {
|
||||||
|
const rowErrors = errors.get(index);
|
||||||
|
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}, [filters.searchText, filters.showErrorsOnly, rowCount]);
|
||||||
|
|
||||||
|
const isFiltering = filters.searchText || filters.showErrorsOnly;
|
||||||
|
|
||||||
// State for the product search template dialog
|
// State for the product search template dialog
|
||||||
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
||||||
|
|
||||||
@@ -112,15 +145,30 @@ export const ValidationToolbar = ({
|
|||||||
placeholder="Filter products..."
|
placeholder="Filter products..."
|
||||||
value={filters.searchText}
|
value={filters.searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
className="pl-9"
|
className={filters.searchText ? "pl-9 pr-8" : "pl-9"}
|
||||||
/>
|
/>
|
||||||
|
{filters.searchText && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchText('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product count */}
|
{/* Product count */}
|
||||||
<span className="text-sm text-muted-foreground">{rowCount} products</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{isFiltering ? `${filteredCount} of ${rowCount} products shown` : `${rowCount} products`}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
{/* Save session */}
|
||||||
|
<SaveSessionButton />
|
||||||
|
|
||||||
{/* Add row */}
|
{/* Add row */}
|
||||||
<Button variant="outline" size="sm" onClick={() => handleAddRow()}>
|
<Button variant="outline" size="sm" onClick={() => handleAddRow()}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* This dramatically improves performance for 100+ option lists.
|
* This dramatically improves performance for 100+ option lists.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef, memo } from 'react';
|
import { useState, useCallback, useRef, useMemo, memo } from 'react';
|
||||||
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
|
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -59,6 +59,7 @@ const ComboboxCellComponent = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||||
const hasFetchedRef = useRef(false);
|
const hasFetchedRef = useRef(false);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Get store state for coordinating with popover close behavior
|
// Get store state for coordinating with popover close behavior
|
||||||
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
||||||
@@ -78,6 +79,10 @@ const ComboboxCellComponent = ({
|
|||||||
if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) {
|
if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Reset scroll position when opening
|
||||||
|
if (isOpen && scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
setOpen(isOpen);
|
setOpen(isOpen);
|
||||||
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
|
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
|
||||||
hasFetchedRef.current = true;
|
hasFetchedRef.current = true;
|
||||||
@@ -90,6 +95,13 @@ const ComboboxCellComponent = ({
|
|||||||
[onFetchOptions, options.length, cellPopoverClosedAt]
|
[onFetchOptions, options.length, cellPopoverClosedAt]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reset scroll position when search filters the list
|
||||||
|
const handleSearchChange = useCallback(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle selection
|
// Handle selection
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(selectedValue: string) => {
|
(selectedValue: string) => {
|
||||||
@@ -105,6 +117,11 @@ const ComboboxCellComponent = ({
|
|||||||
e.currentTarget.scrollTop += e.deltaY;
|
e.currentTarget.scrollTop += e.deltaY;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Sort options alphabetically by label for consistent display
|
||||||
|
const sortedOptions = useMemo(() => {
|
||||||
|
return [...options].sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
@@ -130,7 +147,10 @@ const ComboboxCellComponent = ({
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[250px] p-0" align="start">
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={`Search ${field.label.toLowerCase()}...`} />
|
<CommandInput
|
||||||
|
placeholder={`Search ${field.label.toLowerCase()}...`}
|
||||||
|
onValueChange={handleSearchChange}
|
||||||
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{isLoadingOptions ? (
|
{isLoadingOptions ? (
|
||||||
<div className="flex items-center justify-center py-6">
|
<div className="flex items-center justify-center py-6">
|
||||||
@@ -140,11 +160,12 @@ const ComboboxCellComponent = ({
|
|||||||
<>
|
<>
|
||||||
<CommandEmpty>No {field.label.toLowerCase()} found.</CommandEmpty>
|
<CommandEmpty>No {field.label.toLowerCase()} found.</CommandEmpty>
|
||||||
<div
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
className="max-h-[200px] overflow-y-auto overscroll-contain"
|
className="max-h-[200px] overflow-y-auto overscroll-contain"
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{options.map((option) => (
|
{sortedOptions.map((option) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.label} // cmdk filters by this value
|
value={option.label} // cmdk filters by this value
|
||||||
|
|||||||
@@ -3,9 +3,14 @@
|
|||||||
*
|
*
|
||||||
* Editable input cell for text, numbers, and price values.
|
* Editable input cell for text, numbers, and price values.
|
||||||
* Memoized to prevent unnecessary re-renders when parent table updates.
|
* Memoized to prevent unnecessary re-renders when parent table updates.
|
||||||
|
*
|
||||||
|
* PRICE PRECISION: For price fields, we store FULL precision internally
|
||||||
|
* (e.g., "3.625") but display rounded to 2 decimals when not focused.
|
||||||
|
* This allows calculations like 2x to use full precision while showing
|
||||||
|
* user-friendly rounded values in the UI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef, memo } from 'react';
|
import { useState, useCallback, useEffect, useRef, memo, useMemo } from 'react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +26,17 @@ import type { ValidationError } from '../../store/types';
|
|||||||
import { ErrorType } from '../../store/types';
|
import { ErrorType } from '../../store/types';
|
||||||
import { useValidationStore } from '../../store/validationStore';
|
import { useValidationStore } from '../../store/validationStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a price value for display (2 decimal places)
|
||||||
|
* Returns the original string if it's not a valid number
|
||||||
|
*/
|
||||||
|
const formatPriceForDisplay = (value: string): string => {
|
||||||
|
if (!value) return value;
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if (isNaN(num)) return value;
|
||||||
|
return num.toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
/** Time window (ms) during which this cell should not focus after a popover closes */
|
/** Time window (ms) during which this cell should not focus after a popover closes */
|
||||||
const POPOVER_CLOSE_DELAY = 150;
|
const POPOVER_CLOSE_DELAY = 150;
|
||||||
|
|
||||||
@@ -43,10 +59,14 @@ const InputCellComponent = ({
|
|||||||
errors,
|
errors,
|
||||||
onBlur,
|
onBlur,
|
||||||
}: InputCellProps) => {
|
}: InputCellProps) => {
|
||||||
|
// Store the full precision value internally
|
||||||
const [localValue, setLocalValue] = useState(String(value ?? ''));
|
const [localValue, setLocalValue] = useState(String(value ?? ''));
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Check if this is a price field
|
||||||
|
const isPriceField = 'price' in field.fieldType && field.fieldType.price;
|
||||||
|
|
||||||
// Get store state for coordinating with popover close behavior
|
// Get store state for coordinating with popover close behavior
|
||||||
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
||||||
|
|
||||||
@@ -57,6 +77,14 @@ const InputCellComponent = ({
|
|||||||
}
|
}
|
||||||
}, [value, isFocused]);
|
}, [value, isFocused]);
|
||||||
|
|
||||||
|
// For price fields: show formatted value when not focused, full precision when focused
|
||||||
|
const displayValue = useMemo(() => {
|
||||||
|
if (isPriceField && !isFocused && localValue) {
|
||||||
|
return formatPriceForDisplay(localValue);
|
||||||
|
}
|
||||||
|
return localValue;
|
||||||
|
}, [isPriceField, isFocused, localValue]);
|
||||||
|
|
||||||
// PERFORMANCE: Only update local state while typing, NOT the store
|
// PERFORMANCE: Only update local state while typing, NOT the store
|
||||||
// The store is updated on blur, which prevents thousands of subscription
|
// The store is updated on blur, which prevents thousands of subscription
|
||||||
// checks per keystroke
|
// checks per keystroke
|
||||||
@@ -86,23 +114,13 @@ const InputCellComponent = ({
|
|||||||
}, [cellPopoverClosedAt]);
|
}, [cellPopoverClosedAt]);
|
||||||
|
|
||||||
// Update store only on blur - this is when validation runs too
|
// Update store only on blur - this is when validation runs too
|
||||||
// Round price fields to 2 decimal places
|
// IMPORTANT: We store FULL precision for price fields to allow accurate calculations
|
||||||
|
// The display formatting happens separately via displayValue
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
|
// Store the full precision value - no rounding here
|
||||||
let valueToSave = localValue;
|
onBlur(localValue);
|
||||||
|
}, [localValue, onBlur]);
|
||||||
// Round price fields to 2 decimal places
|
|
||||||
if ('price' in field.fieldType && field.fieldType.price && localValue) {
|
|
||||||
const numValue = parseFloat(localValue);
|
|
||||||
if (!isNaN(numValue)) {
|
|
||||||
valueToSave = numValue.toFixed(2);
|
|
||||||
setLocalValue(valueToSave);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBlur(valueToSave);
|
|
||||||
}, [localValue, onBlur, field.fieldType]);
|
|
||||||
|
|
||||||
// Process errors - show icon only for non-required errors when field has value
|
// Process errors - show icon only for non-required errors when field has value
|
||||||
// Don't show error icon while user is actively editing (focused)
|
// Don't show error icon while user is actively editing (focused)
|
||||||
@@ -129,7 +147,7 @@ const InputCellComponent = ({
|
|||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={localValue}
|
value={displayValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { X, Loader2, Sparkles, AlertCircle, Check, ChevronDown, ChevronUp } from 'lucide-react';
|
import { X, Loader2, Sparkles, AlertCircle, Check } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Field, SelectOption } from '../../../../types';
|
import type { Field, SelectOption } from '../../../../types';
|
||||||
import type { ValidationError } from '../../store/types';
|
import type { ValidationError } from '../../store/types';
|
||||||
@@ -55,9 +55,10 @@ interface MultilineInputProps {
|
|||||||
const MultilineInputComponent = ({
|
const MultilineInputComponent = ({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
|
productIndex,
|
||||||
isValidating,
|
isValidating,
|
||||||
errors,
|
errors,
|
||||||
onChange,
|
onChange: _onChange, // Unused - onBlur handles both update and validation
|
||||||
onBlur,
|
onBlur,
|
||||||
aiSuggestion,
|
aiSuggestion,
|
||||||
isAiValidating,
|
isAiValidating,
|
||||||
@@ -68,10 +69,25 @@ const MultilineInputComponent = ({
|
|||||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
||||||
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
||||||
const [editedSuggestion, setEditedSuggestion] = useState('');
|
const [editedSuggestion, setEditedSuggestion] = useState('');
|
||||||
|
const [popoverWidth, setPopoverWidth] = useState(400);
|
||||||
|
const [popoverHeight, setPopoverHeight] = useState<number | undefined>(undefined);
|
||||||
|
const resizeContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const cellRef = useRef<HTMLDivElement>(null);
|
const cellRef = useRef<HTMLDivElement>(null);
|
||||||
const preventReopenRef = useRef(false);
|
const preventReopenRef = useRef(false);
|
||||||
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
|
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
|
||||||
const intentionalCloseRef = useRef(false);
|
const intentionalCloseRef = useRef(false);
|
||||||
|
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
// Tracks the value when popover opened, to detect actual changes
|
||||||
|
const initialEditValueRef = useRef('');
|
||||||
|
// Ref for the right-side header+issues area to measure its height for left-side spacer
|
||||||
|
const aiHeaderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [aiHeaderHeight, setAiHeaderHeight] = useState(0);
|
||||||
|
|
||||||
|
// Get the product name for this row from the store
|
||||||
|
const productName = useValidationStore(
|
||||||
|
(s) => s.rows.find((row) => row.__index === productIndex)?.name as string | undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Get store state and actions for coordinating popover close behavior across cells
|
// Get store state and actions for coordinating popover close behavior across cells
|
||||||
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
||||||
@@ -112,11 +128,92 @@ const MultilineInputComponent = ({
|
|||||||
}
|
}
|
||||||
}, [aiSuggestion?.suggestion]);
|
}, [aiSuggestion?.suggestion]);
|
||||||
|
|
||||||
|
// Auto-resize a textarea to fit its content
|
||||||
|
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => {
|
||||||
|
if (!textarea) return;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-resize main textarea when value changes or popover opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (popoverOpen) {
|
||||||
|
// Small delay to ensure textarea is rendered
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
autoResizeTextarea(mainTextareaRef.current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [popoverOpen, editValue, autoResizeTextarea]);
|
||||||
|
|
||||||
|
// Auto-resize suggestion textarea when expanded/visible or value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiSuggestionExpanded || (popoverOpen && hasAiSuggestion)) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
autoResizeTextarea(suggestionTextareaRef.current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [aiSuggestionExpanded, popoverOpen, hasAiSuggestion, editedSuggestion, autoResizeTextarea]);
|
||||||
|
|
||||||
|
// Set initial popover height to fit the tallest textarea content, capped by window height.
|
||||||
|
// Only applies on desktop (lg breakpoint) — mobile uses natural flow with individually resizable textareas.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!popoverOpen) { setPopoverHeight(undefined); return; }
|
||||||
|
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
|
||||||
|
if (!isDesktop) { setPopoverHeight(undefined); return; }
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
const main = mainTextareaRef.current;
|
||||||
|
const suggestion = suggestionTextareaRef.current;
|
||||||
|
const container = resizeContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Get textarea natural content heights
|
||||||
|
const mainScrollH = main ? main.scrollHeight : 0;
|
||||||
|
const suggestionScrollH = suggestion ? suggestion.scrollHeight : 0;
|
||||||
|
const tallestTextarea = Math.max(mainScrollH, suggestionScrollH);
|
||||||
|
|
||||||
|
// Measure chrome for both columns (everything except the textarea)
|
||||||
|
const leftChrome = main ? (main.closest('[data-col="left"]')?.scrollHeight ?? 0) - main.offsetHeight : 0;
|
||||||
|
const rightChrome = suggestion ? (suggestion.closest('[data-col="right"]')?.scrollHeight ?? 0) - suggestion.offsetHeight : 0;
|
||||||
|
const chrome = Math.max(leftChrome, rightChrome);
|
||||||
|
|
||||||
|
const naturalHeight = chrome + tallestTextarea;
|
||||||
|
const maxHeight = Math.floor(window.innerHeight * 0.7);
|
||||||
|
setPopoverHeight(Math.max(Math.min(naturalHeight, maxHeight), 200));
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, [popoverOpen]);
|
||||||
|
|
||||||
|
// Measure the right-side header+issues area so the left spacer matches.
|
||||||
|
// Uses rAF because Radix portals mount asynchronously, so the ref is null on the first synchronous run.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!popoverOpen || !hasAiSuggestion) { setAiHeaderHeight(0); return; }
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
const el = aiHeaderRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
observer = new ResizeObserver(([entry]) => {
|
||||||
|
setAiHeaderHeight(entry.contentRect.height-7);
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
observer?.disconnect();
|
||||||
|
};
|
||||||
|
}, [popoverOpen, hasAiSuggestion]);
|
||||||
|
|
||||||
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
|
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
|
||||||
const wasPopoverRecentlyClosed = useCallback(() => {
|
const wasPopoverRecentlyClosed = useCallback(() => {
|
||||||
return Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY;
|
return Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY;
|
||||||
}, [cellPopoverClosedAt]);
|
}, [cellPopoverClosedAt]);
|
||||||
|
|
||||||
|
// Calculate and set popover width based on cell width
|
||||||
|
const updatePopoverWidth = useCallback(() => {
|
||||||
|
if (cellRef.current) {
|
||||||
|
setPopoverWidth(Math.max(cellRef.current.offsetWidth, 200));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle trigger click to toggle the popover
|
// Handle trigger click to toggle the popover
|
||||||
const handleTriggerClick = useCallback(
|
const handleTriggerClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
@@ -126,7 +223,7 @@ const MultilineInputComponent = ({
|
|||||||
preventReopenRef.current = false;
|
preventReopenRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block opening if another popover was just closed
|
// Block opening if another popover was just closed
|
||||||
if (wasPopoverRecentlyClosed()) {
|
if (wasPopoverRecentlyClosed()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -136,23 +233,26 @@ const MultilineInputComponent = ({
|
|||||||
|
|
||||||
// Only process if not already open
|
// Only process if not already open
|
||||||
if (!popoverOpen) {
|
if (!popoverOpen) {
|
||||||
|
updatePopoverWidth();
|
||||||
setPopoverOpen(true);
|
setPopoverOpen(true);
|
||||||
// Initialize edit value from the current display
|
// Initialize edit value from the current display and track it for change detection
|
||||||
setEditValue(localDisplayValue || String(value ?? ''));
|
const initValue = localDisplayValue || String(value ?? '');
|
||||||
|
setEditValue(initValue);
|
||||||
|
initialEditValueRef.current = initValue;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed]
|
[popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed, updatePopoverWidth]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle immediate close of popover (used by close button and actions - intentional closes)
|
// Handle immediate close of popover (used by close button and actions - intentional closes)
|
||||||
const handleClosePopover = useCallback(() => {
|
const handleClosePopover = useCallback(() => {
|
||||||
// Only process if we have changes
|
// Only process if the user actually changed the value
|
||||||
if (editValue !== value || editValue !== localDisplayValue) {
|
if (editValue !== initialEditValueRef.current) {
|
||||||
// Update local display immediately
|
// Update local display immediately
|
||||||
setLocalDisplayValue(editValue);
|
setLocalDisplayValue(editValue);
|
||||||
|
|
||||||
// Queue up the change
|
// onBlur handles both cell update and validation (don't call onChange first
|
||||||
onChange(editValue);
|
// as it would update the store before onBlur can capture previousValue)
|
||||||
onBlur(editValue);
|
onBlur(editValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +268,7 @@ const MultilineInputComponent = ({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
preventReopenRef.current = false;
|
preventReopenRef.current = false;
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [editValue, value, localDisplayValue, onChange, onBlur]);
|
}, [editValue, onBlur]);
|
||||||
|
|
||||||
// Handle popover open/close (called by Radix for click-outside and escape key)
|
// Handle popover open/close (called by Radix for click-outside and escape key)
|
||||||
const handlePopoverOpenChange = useCallback(
|
const handlePopoverOpenChange = useCallback(
|
||||||
@@ -183,10 +283,10 @@ const MultilineInputComponent = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a click-outside close - save changes and signal other cells
|
// This is a click-outside close - only save if user actually changed the value
|
||||||
if (editValue !== value || editValue !== localDisplayValue) {
|
if (editValue !== initialEditValueRef.current) {
|
||||||
setLocalDisplayValue(editValue);
|
setLocalDisplayValue(editValue);
|
||||||
onChange(editValue);
|
// onBlur handles both cell update and validation
|
||||||
onBlur(editValue);
|
onBlur(editValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,28 +305,33 @@ const MultilineInputComponent = ({
|
|||||||
if (wasPopoverRecentlyClosed()) {
|
if (wasPopoverRecentlyClosed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditValue(localDisplayValue || String(value ?? ''));
|
updatePopoverWidth();
|
||||||
|
// Initialize edit value and track it for change detection
|
||||||
|
const initValue = localDisplayValue || String(value ?? '');
|
||||||
|
setEditValue(initValue);
|
||||||
|
initialEditValueRef.current = initValue;
|
||||||
setPopoverOpen(true);
|
setPopoverOpen(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[value, popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onChange, onBlur, setCellPopoverClosed]
|
[popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onBlur, setCellPopoverClosed, updatePopoverWidth, value]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle direct input change
|
// Handle direct input change
|
||||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setEditValue(e.target.value);
|
setEditValue(e.target.value);
|
||||||
}, []);
|
autoResizeTextarea(e.target);
|
||||||
|
}, [autoResizeTextarea]);
|
||||||
|
|
||||||
// Handle accepting the AI suggestion (possibly edited)
|
// Handle accepting the AI suggestion (possibly edited)
|
||||||
const handleAcceptSuggestion = useCallback(() => {
|
const handleAcceptSuggestion = useCallback(() => {
|
||||||
// Use the edited suggestion
|
// Use the edited suggestion
|
||||||
setEditValue(editedSuggestion);
|
setEditValue(editedSuggestion);
|
||||||
setLocalDisplayValue(editedSuggestion);
|
setLocalDisplayValue(editedSuggestion);
|
||||||
onChange(editedSuggestion);
|
// onBlur handles both cell update and validation
|
||||||
onBlur(editedSuggestion);
|
onBlur(editedSuggestion);
|
||||||
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
|
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
|
||||||
setAiSuggestionExpanded(false);
|
setAiSuggestionExpanded(false);
|
||||||
}, [editedSuggestion, onChange, onBlur, onDismissAiSuggestion]);
|
}, [editedSuggestion, onBlur, onDismissAiSuggestion]);
|
||||||
|
|
||||||
// Handle dismissing the AI suggestion
|
// Handle dismissing the AI suggestion
|
||||||
const handleDismissSuggestion = useCallback(() => {
|
const handleDismissSuggestion = useCallback(() => {
|
||||||
@@ -243,7 +348,7 @@ const MultilineInputComponent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full relative" ref={cellRef}>
|
<div className="w-full relative" ref={cellRef}>
|
||||||
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
|
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange} modal>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -270,9 +375,13 @@ const MultilineInputComponent = ({
|
|||||||
if (wasPopoverRecentlyClosed()) {
|
if (wasPopoverRecentlyClosed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
updatePopoverWidth();
|
||||||
setAiSuggestionExpanded(true);
|
setAiSuggestionExpanded(true);
|
||||||
setPopoverOpen(true);
|
setPopoverOpen(true);
|
||||||
setEditValue(localDisplayValue || String(value ?? ''));
|
// Initialize edit value and track it for change detection
|
||||||
|
const initValue = localDisplayValue || String(value ?? '');
|
||||||
|
setEditValue(initValue);
|
||||||
|
initialEditValueRef.current = initValue;
|
||||||
}}
|
}}
|
||||||
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors"
|
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors"
|
||||||
title="View AI suggestion"
|
title="View AI suggestion"
|
||||||
@@ -302,14 +411,30 @@ const MultilineInputComponent = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="p-0 shadow-lg rounded-md"
|
className="p-0 shadow-lg rounded-md max-lg:!w-[95vw]"
|
||||||
style={{ width: Math.max(cellRef.current?.offsetWidth || 400, 400) }}
|
style={{ width: hasAiSuggestion ? popoverWidth * 2 : popoverWidth }}
|
||||||
align="start"
|
align="start"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
sideOffset={-65}
|
sideOffset={-65}
|
||||||
|
ref={(node) => {
|
||||||
|
// Override Radix popper positioning to center on screen when AI suggestion is showing
|
||||||
|
if (node && hasAiSuggestion) {
|
||||||
|
const wrapper = node.parentElement;
|
||||||
|
if (wrapper?.hasAttribute('data-radix-popper-content-wrapper')) {
|
||||||
|
wrapper.style.position = 'fixed';
|
||||||
|
wrapper.style.top = '50%';
|
||||||
|
wrapper.style.left = '50%';
|
||||||
|
wrapper.style.transform = 'translate(-50%, -50%)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div
|
||||||
|
ref={resizeContainerRef}
|
||||||
|
className="flex flex-col lg:flex-row items-stretch lg:resize-y lg:overflow-auto lg:min-h-[120px] max-h-[85vh] overflow-y-auto lg:max-h-none"
|
||||||
|
style={popoverHeight ? { height: popoverHeight } : undefined}
|
||||||
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -321,93 +446,115 @@ const MultilineInputComponent = ({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Main textarea */}
|
{/* Main textarea */}
|
||||||
|
<div data-col="left" className="flex flex-col min-h-0 w-full lg:w-1/2">
|
||||||
|
<div className={cn(hasAiSuggestion ? 'px-3 py-2 bg-accent' : '', 'flex flex-col flex-1 min-h-0')}>
|
||||||
|
{/* Product name - shown inline on mobile, in measured spacer on desktop */}
|
||||||
|
{hasAiSuggestion && productName && (
|
||||||
|
<div className="flex-shrink-0 flex flex-col lg:hidden px-1 mb-2">
|
||||||
|
<div className="text-sm font-medium text-foreground mb-1">Editing description for:</div>
|
||||||
|
<div className="text-md font-semibold text-foreground line-clamp-1">{productName}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasAiSuggestion && aiHeaderHeight > 0 && (
|
||||||
|
<div className="flex-shrink-0 hidden lg:flex items-start" style={{ height: aiHeaderHeight }}>
|
||||||
|
{productName && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="text-sm font-medium text-foreground px-1 mb-1">Editing description for:</div>
|
||||||
|
<div className="text-md font-semibold text-foreground line-clamp-1 px-1">{productName}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasAiSuggestion && <div className="text-sm mb-1 font-medium flex items-center gap-2 flex-shrink-0">
|
||||||
|
Current Description:
|
||||||
|
</div>}
|
||||||
|
{/* Dynamic spacer matching the right-side header+issues height */}
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
|
ref={mainTextareaRef}
|
||||||
value={editValue}
|
value={editValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onWheel={handleTextareaWheel}
|
onWheel={handleTextareaWheel}
|
||||||
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-y"
|
className={cn("overflow-y-auto overscroll-contain text-sm lg:flex-1 resize-y lg:resize-none bg-white min-h-[120px] lg:min-h-0")}
|
||||||
placeholder={`Enter ${field.label || 'text'}...`}
|
placeholder={`Enter ${field.label || 'text'}...`}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
{hasAiSuggestion && <div className="h-[43px] flex-shrink-0 hidden lg:block" />}
|
||||||
|
</div></div>
|
||||||
{/* AI Suggestion section */}
|
{/* AI Suggestion section */}
|
||||||
{hasAiSuggestion && (
|
{hasAiSuggestion && (
|
||||||
<div className="border-t border-purple-200 dark:border-purple-800 bg-purple-50/80 dark:bg-purple-950/30">
|
<div data-col="right" className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
|
||||||
{/* Collapsed header - always visible */}
|
{/* Measured header + issues area (mirrored as spacer on the left) */}
|
||||||
<button
|
<div ref={aiHeaderRef} className="flex-shrink-0">
|
||||||
type="button"
|
{/* Header */}
|
||||||
onClick={() => setAiSuggestionExpanded(!aiSuggestionExpanded)}
|
<div className="w-full flex items-center justify-between px-3 py-2">
|
||||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-purple-100/50 dark:hover:bg-purple-900/30 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
|
||||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
|
||||||
AI Suggestion
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-purple-500 dark:text-purple-400">
|
|
||||||
({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{aiSuggestionExpanded ? (
|
|
||||||
<ChevronUp className="h-4 w-4 text-purple-400" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4 text-purple-400" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expanded content */}
|
|
||||||
{aiSuggestionExpanded && (
|
|
||||||
<div className="px-3 pb-3 space-y-3">
|
|
||||||
{/* Issues list */}
|
|
||||||
{aiIssues.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{aiIssues.map((issue, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
|
|
||||||
>
|
|
||||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
|
||||||
<span>{issue}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Editable suggestion */}
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-purple-500 dark:text-purple-400 mb-1 font-medium">
|
|
||||||
Suggested (editable):
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
value={editedSuggestion}
|
|
||||||
onChange={(e) => setEditedSuggestion(e.target.value)}
|
|
||||||
onWheel={handleTextareaWheel}
|
|
||||||
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||||
size="sm"
|
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||||
variant="outline"
|
AI Suggestion
|
||||||
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
</span>
|
||||||
onClick={handleAcceptSuggestion}
|
<span className="text-xs text-purple-500 dark:text-purple-400">
|
||||||
>
|
({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
|
||||||
<Check className="h-3 w-3 mr-1" />
|
</span>
|
||||||
Replace With Suggestion
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
|
||||||
onClick={handleDismissSuggestion}
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Issues list */}
|
||||||
|
{aiIssues.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 px-3 pb-3">
|
||||||
|
{aiIssues.map((issue, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
|
||||||
|
{/* Editable suggestion */}
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0">
|
||||||
|
Suggested (editable):
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref={suggestionTextareaRef}
|
||||||
|
value={editedSuggestion}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditedSuggestion(e.target.value);
|
||||||
|
autoResizeTextarea(e.target);
|
||||||
|
}}
|
||||||
|
onWheel={handleTextareaWheel}
|
||||||
|
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y lg:resize-none lg:flex-1 min-h-[120px] lg:min-h-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||||
|
onClick={handleAcceptSuggestion}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Replace With Suggestion
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||||
|
onClick={handleDismissSuggestion}
|
||||||
|
>
|
||||||
|
Ignore
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const SelectCellComponent = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isFetchingOptions, setIsFetchingOptions] = useState(false);
|
const [isFetchingOptions, setIsFetchingOptions] = useState(false);
|
||||||
const hasFetchedRef = useRef(false);
|
const hasFetchedRef = useRef(false);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Get store state for coordinating with popover close behavior
|
// Get store state for coordinating with popover close behavior
|
||||||
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
||||||
@@ -98,11 +99,22 @@ const SelectCellComponent = ({
|
|||||||
setIsFetchingOptions(false);
|
setIsFetchingOptions(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Reset scroll position when opening
|
||||||
|
if (isOpen && scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
setOpen(isOpen);
|
setOpen(isOpen);
|
||||||
},
|
},
|
||||||
[onFetchOptions, options.length, cellPopoverClosedAt]
|
[onFetchOptions, options.length, cellPopoverClosedAt]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reset scroll position when search filters the list
|
||||||
|
const handleSearchChange = useCallback(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle selection
|
// Handle selection
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(selectedValue: string) => {
|
(selectedValue: string) => {
|
||||||
@@ -118,6 +130,11 @@ const SelectCellComponent = ({
|
|||||||
e.currentTarget.scrollTop += e.deltaY;
|
e.currentTarget.scrollTop += e.deltaY;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Sort options alphabetically by label for consistent display
|
||||||
|
const sortedOptions = useMemo(() => {
|
||||||
|
return [...options].sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
// Find display label for current value
|
// Find display label for current value
|
||||||
// IMPORTANT: We need to match against both string and number value types
|
// IMPORTANT: We need to match against both string and number value types
|
||||||
const displayLabel = useMemo(() => {
|
const displayLabel = useMemo(() => {
|
||||||
@@ -182,7 +199,11 @@ const SelectCellComponent = ({
|
|||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<Command shouldFilter={true}>
|
<Command shouldFilter={true}>
|
||||||
<CommandInput placeholder="Search..." className="h-9" />
|
<CommandInput
|
||||||
|
placeholder="Search..."
|
||||||
|
className="h-9"
|
||||||
|
onValueChange={handleSearchChange}
|
||||||
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{isLoadingOptions ? (
|
{isLoadingOptions ? (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
@@ -192,11 +213,12 @@ const SelectCellComponent = ({
|
|||||||
<>
|
<>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
<div
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
className="max-h-[200px] overflow-y-auto overscroll-contain"
|
className="max-h-[200px] overflow-y-auto overscroll-contain"
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{options.map((option) => (
|
{sortedOptions.map((option) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.label}
|
value={option.label}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useTemplates,
|
useTemplates,
|
||||||
useTemplatesLoading,
|
useTemplatesLoading,
|
||||||
useTemplateState,
|
useTemplateState,
|
||||||
|
useFields,
|
||||||
} from '../store/selectors';
|
} from '../store/selectors';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
@@ -41,6 +42,7 @@ export const useTemplateManagement = () => {
|
|||||||
const templates = useTemplates();
|
const templates = useTemplates();
|
||||||
const templatesLoading = useTemplatesLoading();
|
const templatesLoading = useTemplatesLoading();
|
||||||
const templateState = useTemplateState();
|
const templateState = useTemplateState();
|
||||||
|
const fields = useFields();
|
||||||
|
|
||||||
// Store actions
|
// Store actions
|
||||||
const setTemplates = useValidationStore((state) => state.setTemplates);
|
const setTemplates = useValidationStore((state) => state.setTemplates);
|
||||||
@@ -101,9 +103,17 @@ export const useTemplateManagement = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract template fields
|
// Extract template fields, excluding those with empty/null/undefined values
|
||||||
|
// This preserves existing row values when template has no value for a field
|
||||||
const templateFields = Object.entries(template).filter(
|
const templateFields = Object.entries(template).filter(
|
||||||
([key]) => !TEMPLATE_EXCLUDE_FIELDS.includes(key)
|
([key, value]) => {
|
||||||
|
if (TEMPLATE_EXCLUDE_FIELDS.includes(key)) return false;
|
||||||
|
// Skip empty values so existing row data is preserved
|
||||||
|
if (value === null || value === undefined || value === '') return false;
|
||||||
|
// Skip empty arrays
|
||||||
|
if (Array.isArray(value) && value.length === 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply template to each row
|
// Apply template to each row
|
||||||
@@ -295,6 +305,7 @@ export const useTemplateManagement = () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get display text for a template (e.g., "Brand - Product Type")
|
* Get display text for a template (e.g., "Brand - Product Type")
|
||||||
|
* Looks up company name from field options instead of showing ID
|
||||||
*/
|
*/
|
||||||
const getTemplateDisplayText = useCallback(
|
const getTemplateDisplayText = useCallback(
|
||||||
(templateId: string | null): string => {
|
(templateId: string | null): string => {
|
||||||
@@ -303,12 +314,20 @@ export const useTemplateManagement = () => {
|
|||||||
const template = templates.find((t) => t.id.toString() === templateId);
|
const template = templates.find((t) => t.id.toString() === templateId);
|
||||||
if (!template) return '';
|
if (!template) return '';
|
||||||
|
|
||||||
// Return "Brand - Product Type" format
|
// Look up company name from field options
|
||||||
const company = template.company || 'Unknown';
|
const companyField = fields.find((f) => f.key === 'company');
|
||||||
|
const companyOptions = companyField?.fieldType?.type === 'select'
|
||||||
|
? companyField.fieldType.options
|
||||||
|
: [];
|
||||||
|
const companyOption = companyOptions?.find(
|
||||||
|
(opt) => String(opt.value) === String(template.company)
|
||||||
|
);
|
||||||
|
const companyName = companyOption?.label || template.company || 'Unknown';
|
||||||
|
|
||||||
const productType = template.product_type || 'Unknown';
|
const productType = template.product_type || 'Unknown';
|
||||||
return `${company} - ${productType}`;
|
return `${companyName} - ${productType}`;
|
||||||
},
|
},
|
||||||
[templates]
|
[templates, fields]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -185,22 +185,14 @@ export const useValidationActions = () => {
|
|||||||
*
|
*
|
||||||
* With 100 rows × 30 fields, the old approach would trigger ~3000 individual
|
* With 100 rows × 30 fields, the old approach would trigger ~3000 individual
|
||||||
* set() calls, each cloning the entire errors Map. This approach triggers ONE.
|
* set() calls, each cloning the entire errors Map. This approach triggers ONE.
|
||||||
*
|
|
||||||
* Also handles:
|
|
||||||
* - Rounding currency fields to 2 decimal places
|
|
||||||
*/
|
*/
|
||||||
const validateAllRows = useCallback(async () => {
|
const validateAllRows = useCallback(async () => {
|
||||||
const { rows: currentRows, fields: currentFields, errors: existingErrors, setBulkValidationResults, updateCell: updateCellAction } = useValidationStore.getState();
|
const { rows: currentRows, fields: currentFields, errors: existingErrors, setBulkValidationResults } = useValidationStore.getState();
|
||||||
|
|
||||||
// Collect ALL errors in plain JS Maps (no Immer overhead)
|
// Collect ALL errors in plain JS Maps (no Immer overhead)
|
||||||
const allErrors = new Map<number, Record<string, ValidationError[]>>();
|
const allErrors = new Map<number, Record<string, ValidationError[]>>();
|
||||||
const allStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>();
|
const allStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>();
|
||||||
|
|
||||||
// Identify price fields for currency rounding
|
|
||||||
const priceFields = currentFields.filter((f: Field<string>) =>
|
|
||||||
'price' in f.fieldType && f.fieldType.price
|
|
||||||
).map((f: Field<string>) => f.key);
|
|
||||||
|
|
||||||
// Process all rows - collect errors without touching the store
|
// Process all rows - collect errors without touching the store
|
||||||
for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) {
|
for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) {
|
||||||
const row = currentRows[rowIndex];
|
const row = currentRows[rowIndex];
|
||||||
@@ -221,20 +213,11 @@ export const useValidationActions = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round currency fields to 2 decimal places on initial load
|
// NOTE: We no longer round price fields on initial load.
|
||||||
for (const priceFieldKey of priceFields) {
|
// Full precision is preserved internally (e.g., "3.625") for accurate calculations.
|
||||||
const value = row[priceFieldKey];
|
// - Display: InputCell shows 2 decimals when not focused
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
// - Calculations: 2x button uses full precision
|
||||||
const numValue = parseFloat(String(value));
|
// - API submission: getCleanedData() formats to 2 decimals
|
||||||
if (!isNaN(numValue)) {
|
|
||||||
const rounded = numValue.toFixed(2);
|
|
||||||
if (String(value) !== rounded) {
|
|
||||||
// Update the cell with rounded value (batched later)
|
|
||||||
updateCellAction(rowIndex, priceFieldKey, rounded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each field
|
// Validate each field
|
||||||
for (const field of currentFields) {
|
for (const field of currentFields) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* 4. Renders the ValidationContainer once initialized
|
* 4. Renders the ValidationContainer once initialized
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useDeferredValue } from 'react';
|
import { useEffect, useRef, useDeferredValue, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useValidationStore } from './store/validationStore';
|
import { useValidationStore } from './store/validationStore';
|
||||||
import { useInitPhase, useIsReady } from './store/selectors';
|
import { useInitPhase, useIsReady } from './store/selectors';
|
||||||
@@ -24,6 +24,36 @@ import config from '@/config';
|
|||||||
import type { ValidationStepProps } from './store/types';
|
import type { ValidationStepProps } from './store/types';
|
||||||
import type { Field, SelectOption } from '../../types';
|
import type { Field, SelectOption } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a fingerprint of the data to detect changes.
|
||||||
|
* This is used to determine if we need to re-initialize the store
|
||||||
|
* when navigating back to this step with potentially modified data.
|
||||||
|
*/
|
||||||
|
const createDataFingerprint = (data: Record<string, unknown>[]): string => {
|
||||||
|
// Sample key fields that are likely to change when user modifies data in previous steps
|
||||||
|
const keyFields = ['supplier', 'company', 'line', 'subline', 'name', 'upc', 'item_number'];
|
||||||
|
|
||||||
|
// Create a simple hash from first few rows + last row + count
|
||||||
|
const sampleSize = Math.min(3, data.length);
|
||||||
|
const samples: string[] = [];
|
||||||
|
|
||||||
|
// First few rows
|
||||||
|
for (let i = 0; i < sampleSize; i++) {
|
||||||
|
const row = data[i];
|
||||||
|
const values = keyFields.map(k => String(row[k] ?? '')).join('|');
|
||||||
|
samples.push(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last row (if different from samples)
|
||||||
|
if (data.length > sampleSize) {
|
||||||
|
const lastRow = data[data.length - 1];
|
||||||
|
const values = keyFields.map(k => String(lastRow[k] ?? '')).join('|');
|
||||||
|
samples.push(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${data.length}:${samples.join(';;')}`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch field options from the API
|
* Fetch field options from the API
|
||||||
*/
|
*/
|
||||||
@@ -105,6 +135,7 @@ export const ValidationStep = ({
|
|||||||
const templatesLoadedRef = useRef(false);
|
const templatesLoadedRef = useRef(false);
|
||||||
const upcValidationStartedRef = useRef(false);
|
const upcValidationStartedRef = useRef(false);
|
||||||
const fieldValidationStartedRef = useRef(false);
|
const fieldValidationStartedRef = useRef(false);
|
||||||
|
const lastDataFingerprintRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
|
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
|
||||||
@@ -132,12 +163,25 @@ export const ValidationStep = ({
|
|||||||
retry: 2,
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current store state to check if we're returning to an already-initialized store
|
// Create a fingerprint of the incoming data to detect changes
|
||||||
const storeRows = useValidationStore((state) => state.rows);
|
const dataFingerprint = useMemo(() => createDataFingerprint(initialData), [initialData]);
|
||||||
|
|
||||||
// Initialize store with data
|
// Initialize store with data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
|
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
|
||||||
|
console.log('[ValidationStep] Data fingerprint:', dataFingerprint, 'Last fingerprint:', lastDataFingerprintRef.current);
|
||||||
|
|
||||||
|
// Check if data has changed since last initialization
|
||||||
|
const dataHasChanged = lastDataFingerprintRef.current !== null && lastDataFingerprintRef.current !== dataFingerprint;
|
||||||
|
|
||||||
|
if (dataHasChanged) {
|
||||||
|
console.log('[ValidationStep] Data has changed - forcing re-initialization');
|
||||||
|
// Reset all refs to allow re-initialization
|
||||||
|
initStartedRef.current = false;
|
||||||
|
templatesLoadedRef.current = false;
|
||||||
|
upcValidationStartedRef.current = false;
|
||||||
|
fieldValidationStartedRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip if already initialized (check both ref AND store state)
|
// Skip if already initialized (check both ref AND store state)
|
||||||
// The ref prevents double-init within the same mount cycle
|
// The ref prevents double-init within the same mount cycle
|
||||||
@@ -148,17 +192,16 @@ export const ValidationStep = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IMPORTANT: Skip initialization if we're returning to an already-ready store
|
// IMPORTANT: Skip initialization if we're returning to an already-ready store
|
||||||
// This happens when navigating back from ImageUploadStep - the store still has
|
// with the SAME data. This happens when navigating back from ImageUploadStep.
|
||||||
// all the validated data, so we don't need to re-run the initialization sequence.
|
// We compare fingerprints to detect if the data has actually changed.
|
||||||
// We check that the store is 'ready' and has matching row count to avoid
|
if (initPhase === 'ready' && !dataHasChanged && lastDataFingerprintRef.current === dataFingerprint) {
|
||||||
// false positives from stale store data.
|
console.log('[ValidationStep] Skipping init - returning to already-ready store with same data');
|
||||||
if (initPhase === 'ready' && storeRows.length === initialData.length && storeRows.length > 0) {
|
|
||||||
console.log('[ValidationStep] Skipping init - returning to already-ready store with', storeRows.length, 'rows');
|
|
||||||
initStartedRef.current = true;
|
initStartedRef.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
initStartedRef.current = true;
|
initStartedRef.current = true;
|
||||||
|
lastDataFingerprintRef.current = dataFingerprint;
|
||||||
|
|
||||||
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
|
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
|
||||||
|
|
||||||
@@ -172,7 +215,7 @@ export const ValidationStep = ({
|
|||||||
console.log('[ValidationStep] Calling initialize()');
|
console.log('[ValidationStep] Calling initialize()');
|
||||||
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
|
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
|
||||||
console.log('[ValidationStep] initialize() called');
|
console.log('[ValidationStep] initialize() called');
|
||||||
}, [initialData, file, initialize, initPhase, storeRows.length]);
|
}, [initialData, file, initialize, initPhase, dataFingerprint]);
|
||||||
|
|
||||||
// Update fields when options are loaded
|
// Update fields when options are loaded
|
||||||
// CRITICAL: Check store state (not ref) because initialize() resets the store
|
// CRITICAL: Check store state (not ref) because initialize() resets the store
|
||||||
|
|||||||
@@ -215,6 +215,20 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
|
|
||||||
deleteRows: (rowIndexes: number[]) => {
|
deleteRows: (rowIndexes: number[]) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
// Collect row IDs to remove from selection before deleting
|
||||||
|
const rowIdsToDelete = new Set<string>();
|
||||||
|
rowIndexes.forEach((index) => {
|
||||||
|
if (index >= 0 && index < state.rows.length) {
|
||||||
|
const rowId = state.rows[index].__index;
|
||||||
|
if (rowId) rowIdsToDelete.add(rowId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear these rows from selectedRows
|
||||||
|
rowIdsToDelete.forEach((rowId) => {
|
||||||
|
state.selectedRows.delete(rowId);
|
||||||
|
});
|
||||||
|
|
||||||
// Sort descending to delete from end first (preserves indices)
|
// Sort descending to delete from end first (preserves indices)
|
||||||
const sorted = [...rowIndexes].sort((a, b) => b - a);
|
const sorted = [...rowIndexes].sort((a, b) => b - a);
|
||||||
sorted.forEach((index) => {
|
sorted.forEach((index) => {
|
||||||
@@ -949,9 +963,25 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
|
|
||||||
getCleanedData: (): CleanRowData[] => {
|
getCleanedData: (): CleanRowData[] => {
|
||||||
const { rows } = get();
|
const { rows } = get();
|
||||||
|
// Price fields that should be formatted to 2 decimal places for API submission
|
||||||
|
const priceFields = ['msrp', 'cost_each'];
|
||||||
|
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { __index, __template, __original, __corrected, __changes, __aiSupplemental, ...cleanRow } = row;
|
const { __index, __template, __original, __corrected, __changes, __aiSupplemental, ...cleanRow } = row;
|
||||||
|
|
||||||
|
// Format price fields to 2 decimal places for API submission
|
||||||
|
// This ensures consistent precision while internal storage keeps full precision for calculations
|
||||||
|
for (const field of priceFields) {
|
||||||
|
const value = cleanRow[field];
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
const num = parseFloat(String(value));
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
cleanRow[field] = num.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return cleanRow as CleanRowData;
|
return cleanRow as CleanRowData;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,10 +34,16 @@ export function calculateEanCheckDigit(eanBody: string): number {
|
|||||||
|
|
||||||
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
|
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
|
||||||
const value = rawValue ?? '';
|
const value = rawValue ?? '';
|
||||||
const str = typeof value === 'string' ? value.trim() : String(value);
|
const originalStr = typeof value === 'string' ? value : String(value);
|
||||||
|
// Strip ALL whitespace (spaces, tabs, etc.) from UPC values - not just leading/trailing
|
||||||
|
const str = originalStr.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
// Track if whitespace was stripped (this alone means we changed the value)
|
||||||
|
const whitespaceStripped = str !== originalStr;
|
||||||
|
|
||||||
if (str === '' || !NUMERIC_REGEX.test(str)) {
|
if (str === '' || !NUMERIC_REGEX.test(str)) {
|
||||||
return { corrected: str, changed: false };
|
// Return stripped version even if not numeric (so non-numeric values still get spaces removed)
|
||||||
|
return { corrected: str, changed: whitespaceStripped };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str.length === 11) {
|
if (str.length === 11) {
|
||||||
@@ -49,15 +55,18 @@ export function correctUpcValue(rawValue: unknown): { corrected: string; changed
|
|||||||
const body = str.slice(0, 11);
|
const body = str.slice(0, 11);
|
||||||
const check = calculateUpcCheckDigit(body);
|
const check = calculateUpcCheckDigit(body);
|
||||||
const corrected = `${body}${check}`;
|
const corrected = `${body}${check}`;
|
||||||
return { corrected, changed: corrected !== str };
|
// Changed if whitespace was stripped OR if check digit was corrected
|
||||||
|
return { corrected, changed: whitespaceStripped || corrected !== str };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str.length === 13) {
|
if (str.length === 13) {
|
||||||
const body = str.slice(0, 12);
|
const body = str.slice(0, 12);
|
||||||
const check = calculateEanCheckDigit(body);
|
const check = calculateEanCheckDigit(body);
|
||||||
const corrected = `${body}${check}`;
|
const corrected = `${body}${check}`;
|
||||||
return { corrected, changed: corrected !== str };
|
// Changed if whitespace was stripped OR if check digit was corrected
|
||||||
|
return { corrected, changed: whitespaceStripped || corrected !== str };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { corrected: str, changed: false };
|
// For other lengths, return stripped value
|
||||||
|
return { corrected: str, changed: whitespaceStripped };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type SubmitOptions = {
|
|||||||
targetEnvironment: "dev" | "prod"
|
targetEnvironment: "dev" | "prod"
|
||||||
useTestDataSource: boolean
|
useTestDataSource: boolean
|
||||||
skipApiSubmission?: boolean
|
skipApiSubmission?: boolean
|
||||||
|
showNewProduct?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RsiProps<T extends string> = {
|
export type RsiProps<T extends string> = {
|
||||||
|
|||||||
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';
|
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
|
||||||
// Use proxy paths when on inventory domains to avoid CORS
|
// Use proxy paths when on inventory domains to avoid CORS
|
||||||
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.acot.site' || window.location.hostname === 'acot.site');
|
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.tools.acherryontop.com' || window.location.hostname === 'tools.acherryontop.com');
|
||||||
|
|
||||||
const liveDashboardConfig = {
|
const liveDashboardConfig = {
|
||||||
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',
|
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',
|
||||||
|
|||||||
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 { AuthContext } from "@/contexts/AuthContext";
|
||||||
import { TemplateForm } from "@/components/templates/TemplateForm";
|
import { TemplateForm } from "@/components/templates/TemplateForm";
|
||||||
|
|
||||||
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
|
type NormalizedProduct = Record<ImportFieldKey | "product_images" | "show_new_product", string | string[] | boolean | null>;
|
||||||
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
|
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
|
||||||
|
|
||||||
interface BackendProductResult {
|
interface BackendProductResult {
|
||||||
@@ -271,200 +271,6 @@ export function Import() {
|
|||||||
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// ========== TEMPORARY TEST DATA ==========
|
|
||||||
// Uncomment the useEffect below to test the results page without submitting actual data
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// // Test scenario: Mix of successful and failed products
|
|
||||||
// const testSubmittedProducts: NormalizedProduct[] = [
|
|
||||||
// {
|
|
||||||
// name: "Test Product 1",
|
|
||||||
// upc: "123456789012",
|
|
||||||
// item_number: "ITEM-001",
|
|
||||||
// company: "Test Company",
|
|
||||||
// line: "Test Line",
|
|
||||||
// subline: "Test Subline",
|
|
||||||
// product_images: ["https://picsum.photos/200/200?random=1"],
|
|
||||||
// short_description: "This is a test product",
|
|
||||||
// retail: "29.99",
|
|
||||||
// wholesale: "15.00",
|
|
||||||
// weight: "1.5",
|
|
||||||
// categories: ["Category 1", "Category 2"],
|
|
||||||
// colors: ["Red", "Blue"],
|
|
||||||
// size_cat: "Medium",
|
|
||||||
// tax_cat: "Taxable",
|
|
||||||
// ship_restrictions: "None",
|
|
||||||
// supplier: "Test Supplier",
|
|
||||||
// artist: null,
|
|
||||||
// themes: ["Theme 1"],
|
|
||||||
// vendor_sku: "VS-001",
|
|
||||||
// publish: true,
|
|
||||||
// list_on_marketplace: false,
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: "Test Product 2",
|
|
||||||
// upc: "234567890123",
|
|
||||||
// item_number: "ITEM-002",
|
|
||||||
// company: "Test Company",
|
|
||||||
// line: "Test Line",
|
|
||||||
// subline: "Test Subline",
|
|
||||||
// product_images: ["https://picsum.photos/200/200?random=2"],
|
|
||||||
// short_description: "Another test product",
|
|
||||||
// retail: "49.99",
|
|
||||||
// wholesale: "25.00",
|
|
||||||
// weight: "2.0",
|
|
||||||
// categories: ["Category 3"],
|
|
||||||
// colors: ["Green"],
|
|
||||||
// size_cat: "Large",
|
|
||||||
// tax_cat: "Taxable",
|
|
||||||
// ship_restrictions: "None",
|
|
||||||
// supplier: "Test Supplier",
|
|
||||||
// artist: "Test Artist",
|
|
||||||
// themes: [],
|
|
||||||
// vendor_sku: "VS-002",
|
|
||||||
// publish: true,
|
|
||||||
// list_on_marketplace: true,
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: "Failed Product 1",
|
|
||||||
// upc: "345678901234",
|
|
||||||
// item_number: "ITEM-003",
|
|
||||||
// company: "Test Company",
|
|
||||||
// line: "Test Line",
|
|
||||||
// subline: null,
|
|
||||||
// product_images: ["https://picsum.photos/200/200?random=3"],
|
|
||||||
// short_description: "This product will fail",
|
|
||||||
// retail: "19.99",
|
|
||||||
// wholesale: "10.00",
|
|
||||||
// weight: "0.5",
|
|
||||||
// categories: [],
|
|
||||||
// colors: [],
|
|
||||||
// size_cat: null,
|
|
||||||
// tax_cat: "Taxable",
|
|
||||||
// ship_restrictions: null,
|
|
||||||
// supplier: null,
|
|
||||||
// artist: null,
|
|
||||||
// themes: [],
|
|
||||||
// vendor_sku: "VS-003",
|
|
||||||
// publish: false,
|
|
||||||
// list_on_marketplace: false,
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: "Failed Product 2",
|
|
||||||
// upc: "456789012345",
|
|
||||||
// item_number: "ITEM-004",
|
|
||||||
// company: "Test Company",
|
|
||||||
// line: null,
|
|
||||||
// subline: null,
|
|
||||||
// product_images: null,
|
|
||||||
// description: "Another failed product",
|
|
||||||
// msrp: "99.99",
|
|
||||||
// cost_each: "50.00",
|
|
||||||
// weight: "5.0",
|
|
||||||
// categories: ["Category 1"],
|
|
||||||
// colors: ["Yellow"],
|
|
||||||
// size_cat: "Small",
|
|
||||||
// tax_cat: null,
|
|
||||||
// ship_restrictions: "Hazmat",
|
|
||||||
// supplier: "Test Supplier",
|
|
||||||
// artist: null,
|
|
||||||
// themes: [],
|
|
||||||
// vendor_sku: null,
|
|
||||||
// publish: true,
|
|
||||||
// list_on_marketplace: false,
|
|
||||||
// },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
// const testSubmittedRows: Data<string>[] = testSubmittedProducts.map(product => ({ ...product } as Data<string>));
|
|
||||||
|
|
||||||
// //Scenario 1: All successful
|
|
||||||
// const testResponse: SubmitNewProductsResponse = {
|
|
||||||
// success: true,
|
|
||||||
// message: "Successfully created 4 products",
|
|
||||||
// data: {
|
|
||||||
// created: [
|
|
||||||
// { pid: 12345, upc: "123456789012", item_number: "ITEM-001" },
|
|
||||||
// { pid: 12346, upc: "234567890123", item_number: "ITEM-002" },
|
|
||||||
// { pid: 12347, upc: "345678901234", item_number: "ITEM-003" },
|
|
||||||
// { pid: 12348, upc: "456789012345", item_number: "ITEM-004" },
|
|
||||||
// ],
|
|
||||||
// errored: [],
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Scenario 2: Partial success (2 created, 2 failed)
|
|
||||||
// const testResponse: SubmitNewProductsResponse = {
|
|
||||||
// success: true,
|
|
||||||
// message: "Created 2 of 4 products. 2 products had errors.",
|
|
||||||
// data: {
|
|
||||||
// created: [
|
|
||||||
// { pid: 12345, upc: "123456789012", item_number: "ITEM-001" },
|
|
||||||
// { pid: 12346, upc: "234567890123", item_number: "ITEM-002" },
|
|
||||||
// ],
|
|
||||||
// errored: [
|
|
||||||
// {
|
|
||||||
// upc: "345678901234",
|
|
||||||
// item_number: "ITEM-003",
|
|
||||||
// error_msg: "Missing required field: supplier",
|
|
||||||
// errors: {
|
|
||||||
// supplier: ["Supplier is required for this product line"],
|
|
||||||
// categories: ["At least one category must be selected"],
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// upc: "456789012345",
|
|
||||||
// item_number: "ITEM-004",
|
|
||||||
// error_msg: "Invalid product configuration",
|
|
||||||
// errors: {
|
|
||||||
// line: ["Product line is required"],
|
|
||||||
// tax_cat: ["Tax category must be specified"],
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// query_id: "1234567890",
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Scenario 3: Complete failure
|
|
||||||
// const testResponse: SubmitNewProductsResponse = {
|
|
||||||
// success: false,
|
|
||||||
// message: "Failed to create products. Please check the errors below.",
|
|
||||||
// data: {
|
|
||||||
// created: [],
|
|
||||||
// errored: [
|
|
||||||
// {
|
|
||||||
// upc: "123456789012",
|
|
||||||
// item_number: "ITEM-001",
|
|
||||||
// error_msg: "A product with this UPC already exists",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// upc: "234567890123",
|
|
||||||
// item_number: "ITEM-002",
|
|
||||||
// error_msg: "Invalid wholesale price",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// upc: "345678901234",
|
|
||||||
// item_number: "ITEM-003",
|
|
||||||
// error_msg: "Missing required field: supplier",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// upc: "456789012345",
|
|
||||||
// item_number: "ITEM-004",
|
|
||||||
// error_msg: "Invalid product configuration",
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
|
|
||||||
// setImportOutcome({
|
|
||||||
// submittedProducts: testSubmittedProducts,
|
|
||||||
// submittedRows: testSubmittedRows,
|
|
||||||
// response: testResponse,
|
|
||||||
// });
|
|
||||||
// }, []);
|
|
||||||
|
|
||||||
// ========== END TEST DATA ==========
|
|
||||||
|
|
||||||
// Fetch initial field options from the API
|
// Fetch initial field options from the API
|
||||||
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
||||||
queryKey: ["import-field-options"],
|
queryKey: ["import-field-options"],
|
||||||
@@ -734,9 +540,13 @@ export function Import() {
|
|||||||
normalizedProductImages = rawProductImages;
|
normalizedProductImages = rawProductImages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve show_new_product flag if it was set
|
||||||
|
const showNewProduct = (row as Record<string, unknown>).show_new_product;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseValues,
|
...baseValues,
|
||||||
product_images: normalizedProductImages,
|
product_images: normalizedProductImages,
|
||||||
|
...(showNewProduct === true && { show_new_product: true }),
|
||||||
} as NormalizedProduct;
|
} as NormalizedProduct;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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",
|
cookieDomainRewrite: "localhost",
|
||||||
},
|
},
|
||||||
"/api/aircall": {
|
"/api/aircall": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/klaviyo": {
|
"/api/klaviyo": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/meta": {
|
"/api/meta": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/gorgias": {
|
"/api/gorgias": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/dashboard-analytics": {
|
"/api/dashboard-analytics": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
cookieDomainRewrite: {
|
cookieDomainRewrite: {
|
||||||
@@ -122,25 +122,25 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/typeform": {
|
"/api/typeform": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/acot": {
|
"/api/acot": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/clarity": {
|
"/api/clarity": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
@@ -161,14 +161,14 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/dashboard-auth": {
|
"/dashboard-auth": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
rewrite: (path) => path.replace("/dashboard-auth", "/auth"),
|
rewrite: (path) => path.replace("/dashboard-auth", "/auth"),
|
||||||
},
|
},
|
||||||
"/auth-inv": {
|
"/auth-inv": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
@@ -195,7 +195,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/chat-api": {
|
"/chat-api": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
@@ -216,7 +216,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/uploads": {
|
"/uploads": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
|
|||||||
Reference in New Issue
Block a user