Add option to not hide submitted products for product import, rework description popover, fix steps
This commit is contained in:
1
CLAUDE.md
Normal file
1
CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead.
|
||||||
@@ -70,130 +70,8 @@ router.get('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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 *
|
|
||||||
`, [
|
|
||||||
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 {
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if session exists
|
|
||||||
const checkResult = await pool.query('SELECT * FROM import_sessions WHERE id = $1', [id]);
|
|
||||||
if (checkResult.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Import session not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(`
|
|
||||||
UPDATE import_sessions
|
|
||||||
SET
|
|
||||||
current_step = $1,
|
|
||||||
data = $2,
|
|
||||||
product_images = $3,
|
|
||||||
global_selections = $4,
|
|
||||||
validation_state = $5,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $6
|
|
||||||
RETURNING *
|
|
||||||
`, [
|
|
||||||
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
|
|
||||||
]);
|
|
||||||
|
|
||||||
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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Autosave - upsert unnamed session for user
|
// 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) => {
|
router.put('/autosave', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@@ -240,7 +118,7 @@ router.put('/autosave', async (req, res) => {
|
|||||||
global_selections = EXCLUDED.global_selections,
|
global_selections = EXCLUDED.global_selections,
|
||||||
validation_state = EXCLUDED.validation_state,
|
validation_state = EXCLUDED.validation_state,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING *
|
RETURNING id, user_id, name, current_step, created_at, updated_at
|
||||||
`, [
|
`, [
|
||||||
user_id,
|
user_id,
|
||||||
current_step,
|
current_step,
|
||||||
@@ -260,32 +138,8 @@ router.put('/autosave', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete unnamed session for user (clear autosave)
|
// Delete unnamed session for user (clear autosave)
|
||||||
|
// IMPORTANT: This must be defined before /:id routes
|
||||||
router.delete('/autosave/:user_id', async (req, res) => {
|
router.delete('/autosave/:user_id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { user_id } = req.params;
|
const { user_id } = req.params;
|
||||||
@@ -295,7 +149,7 @@ router.delete('/autosave/:user_id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'DELETE FROM import_sessions WHERE user_id = $1 AND name IS NULL RETURNING *',
|
'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]
|
[user_id]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -313,6 +167,164 @@ router.delete('/autosave/:user_id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Error handling middleware
|
||||||
router.use((err, req, res, next) => {
|
router.use((err, req, res, next) => {
|
||||||
console.error('Import sessions route error:', err);
|
console.error('Import sessions route error:', err);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* CloseConfirmationDialog Component
|
* CloseConfirmationDialog Component
|
||||||
*
|
*
|
||||||
* Shown when user attempts to close the import modal.
|
* Single dialog shown when user attempts to close the import modal.
|
||||||
* Offers options to save the session before closing.
|
* Named sessions: Save & Exit or Cancel.
|
||||||
|
* Unnamed sessions: Keep (autosave), Save with Name (inline input), Discard, or Cancel.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useContext, useEffect } from 'react';
|
||||||
import { Loader2, Save } from 'lucide-react';
|
import { Loader2, Save, Trash2, ArrowLeft } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
@@ -21,9 +21,9 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { useImportSession } from '@/contexts/ImportSessionContext';
|
import { useImportSession } from '@/contexts/ImportSessionContext';
|
||||||
import { toast } from 'sonner';
|
import { AuthContext } from '@/contexts/AuthContext';
|
||||||
|
import { deleteAutosaveSession, deleteSession as deleteSessionApi } from '@/services/importSessionApi';
|
||||||
|
|
||||||
interface CloseConfirmationDialogProps {
|
interface CloseConfirmationDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -38,83 +38,97 @@ export function CloseConfirmationDialog({
|
|||||||
}: CloseConfirmationDialogProps) {
|
}: CloseConfirmationDialogProps) {
|
||||||
const {
|
const {
|
||||||
sessionName,
|
sessionName,
|
||||||
|
sessionId,
|
||||||
isDirty,
|
isDirty,
|
||||||
forceSave,
|
forceSave,
|
||||||
saveAsNamed,
|
saveAsNamed,
|
||||||
|
clearSession,
|
||||||
getSuggestedSessionName,
|
getSuggestedSessionName,
|
||||||
isSaving,
|
|
||||||
} = useImportSession();
|
} = useImportSession();
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
const [showNameInput, setShowNameInput] = useState(false);
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showNameInput, setShowNameInput] = useState(false);
|
||||||
|
|
||||||
// Reset state when dialog opens
|
// Reset state when dialog opens
|
||||||
const handleOpenChange = (newOpen: boolean) => {
|
useEffect(() => {
|
||||||
if (newOpen) {
|
if (open) {
|
||||||
// Pre-populate with suggested name when opening
|
|
||||||
const suggested = getSuggestedSessionName();
|
const suggested = getSuggestedSessionName();
|
||||||
setName(suggested || '');
|
setName(suggested || '');
|
||||||
setShowNameInput(false);
|
|
||||||
setError(null);
|
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);
|
onOpenChange(newOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle "Save & Exit" for named sessions
|
// Save & Exit (for named sessions, or "Keep" for unnamed)
|
||||||
const handleSaveAndExit = async () => {
|
const handleSaveAndExit = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await forceSave();
|
await forceSave();
|
||||||
toast.success('Session saved');
|
|
||||||
onConfirmClose();
|
onConfirmClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save:', err);
|
console.error('Failed to save:', err);
|
||||||
toast.error('Failed to save session');
|
onConfirmClose();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle "Save As" for unnamed sessions
|
// Save with the entered name, then exit
|
||||||
const handleSaveAs = async () => {
|
const handleSaveWithName = async () => {
|
||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
if (!trimmedName) {
|
if (!trimmedName) {
|
||||||
setError('Please enter a name for the session');
|
setError('Enter a name');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveAsNamed(trimmedName);
|
await saveAsNamed(trimmedName);
|
||||||
toast.success('Session saved');
|
|
||||||
onConfirmClose();
|
onConfirmClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to save session');
|
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle "Exit Without Saving" - still auto-saves as unnamed
|
// Discard session and exit
|
||||||
const handleExitWithoutNaming = async () => {
|
const handleDiscardAndExit = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await forceSave();
|
if (sessionId) {
|
||||||
|
await deleteSessionApi(sessionId);
|
||||||
|
} else if (user?.id) {
|
||||||
|
await deleteAutosaveSession(user.id);
|
||||||
|
}
|
||||||
|
clearSession();
|
||||||
onConfirmClose();
|
onConfirmClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to autosave:', err);
|
console.error('Failed to discard session:', err);
|
||||||
// Still close even if autosave fails
|
clearSession();
|
||||||
onConfirmClose();
|
onConfirmClose();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isProcessing = saving || isSaving;
|
// --- Named session: simple save & exit ---
|
||||||
|
|
||||||
// Session is already named
|
|
||||||
if (sessionName) {
|
if (sessionName) {
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||||
@@ -131,81 +145,12 @@ export function CloseConfirmationDialog({
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isProcessing}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={isProcessing}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleSaveAndExit} disabled={isProcessing}>
|
<Button onClick={handleSaveAndExit} disabled={isProcessing}>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<>
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
'Save & Exit'
|
'Save & Exit'
|
||||||
)}
|
)}
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialogPortal>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session is unnamed - show option to name or exit
|
|
||||||
if (showNameInput) {
|
|
||||||
return (
|
|
||||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<AlertDialogPortal>
|
|
||||||
<AlertDialogOverlay className="z-[1400]" />
|
|
||||||
<AlertDialogContent className="z-[1500]">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
|
||||||
<Save className="h-5 w-5" />
|
|
||||||
Save Session
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Enter a name for your session so you can find it later.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="session-name-close">Session Name</Label>
|
|
||||||
<Input
|
|
||||||
id="session-name-close"
|
|
||||||
placeholder="e.g., Spring 2025 Products"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => {
|
|
||||||
setName(e.target.value);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && !isProcessing && name.trim()) {
|
|
||||||
handleSaveAs();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isProcessing}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setShowNameInput(false)}
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSaveAs} disabled={isProcessing || !name.trim()}>
|
|
||||||
{isProcessing ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
Save & Exit
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -214,42 +159,89 @@ export function CloseConfirmationDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial state - unnamed session, ask what to do
|
// --- Unnamed session: all options in one view ---
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay className="z-[1400]" />
|
<AlertDialogOverlay className="z-[1400]" />
|
||||||
<AlertDialogContent className="z-[1500]">
|
<AlertDialogContent className={`z-[1500] ${showNameInput ? 'max-w-[400px]' : 'max-w-[700px]'}`}>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Exit Import</AlertDialogTitle>
|
<AlertDialogTitle>{showNameInput ? 'Save As...' : 'Exit Product Import'}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Your progress will be automatically saved. You can restore it later from the upload step
|
{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.'}
|
||||||
as "Previous Session", or save it with a name for easier reference.
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
|
|
||||||
|
{/* 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">
|
<AlertDialogCancel disabled={isProcessing} className="mt-0">
|
||||||
Continue Editing
|
<ArrowLeft className="h-4 w-4" /> Continue Editing
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowNameInput(true)}
|
onClick={handleDiscardAndExit}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
|
className="text-destructive hover:text-destructive/90"
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
Save with Name
|
|
||||||
</Button>
|
|
||||||
<AlertDialogAction onClick={handleExitWithoutNaming} disabled={isProcessing}>
|
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
'Exit'
|
<Trash2 className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</AlertDialogAction>
|
Discard
|
||||||
</AlertDialogFooter>
|
</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>
|
</AlertDialogContent>
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
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,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} 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"
|
import { CloseConfirmationDialog } from "./CloseConfirmationDialog"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -23,55 +17,82 @@ type Props = {
|
|||||||
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
|
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
|
||||||
const { rtl } = 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
|
||||||
|
const closingRef = useRef(false)
|
||||||
|
|
||||||
// Create a handler that resets scroll positions before closing
|
// Called after user confirms close in the dialog
|
||||||
const handleClose = useCallback(() => {
|
const handleConfirmClose = useCallback(() => {
|
||||||
// Reset all scroll positions in the dialog
|
// 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>
|
||||||
|
|
||||||
<CloseConfirmationDialog
|
<CloseConfirmationDialog
|
||||||
open={showCloseAlert}
|
open={showCloseAlert}
|
||||||
onOpenChange={setShowCloseAlert}
|
onOpenChange={setShowCloseAlert}
|
||||||
onConfirmClose={handleClose}
|
onConfirmClose={handleConfirmClose}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ 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 { useImportAutosave } from "@/hooks/useImportAutosave";
|
||||||
import { useImportSession } from "@/contexts/ImportSessionContext";
|
import { useImportSession } from "@/contexts/ImportSessionContext";
|
||||||
@@ -52,9 +62,11 @@ 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
|
// Import session context for cleanup on submit and global selections
|
||||||
const { deleteSession: deleteImportSession } = useImportSession();
|
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);
|
||||||
@@ -106,14 +118,21 @@ export const ImageUploadStep = ({
|
|||||||
current_step: 'imageUpload',
|
current_step: 'imageUpload',
|
||||||
data: data as any[], // Product data
|
data: data as any[], // Product data
|
||||||
product_images: productImages,
|
product_images: productImages,
|
||||||
|
global_selections: getGlobalSelections(),
|
||||||
};
|
};
|
||||||
}, [data, productImages]),
|
}, [data, productImages, getGlobalSelections]),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark dirty when images change
|
// 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(() => {
|
useEffect(() => {
|
||||||
markDirty();
|
if (prevProductImagesRef.current !== productImages) {
|
||||||
}, [productImages, markDirty]);
|
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(
|
||||||
@@ -196,14 +215,17 @@ 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);
|
||||||
@@ -221,10 +243,19 @@ export const ImageUploadStep = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission, deleteImportSession]);
|
}, [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">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -335,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 && (
|
||||||
@@ -389,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +1,26 @@
|
|||||||
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)
|
||||||
@@ -59,13 +53,16 @@ export const Steps = () => {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,12 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Import session context for session restoration
|
// Import session context for session restoration
|
||||||
const { loadSession } = useImportSession()
|
const { loadSession, setGlobalSelections: setSessionGlobalSelections } = useImportSession()
|
||||||
|
|
||||||
|
// Sync global selections to session context for autosave
|
||||||
|
useEffect(() => {
|
||||||
|
setSessionGlobalSelections(persistedGlobalSelections)
|
||||||
|
}, [persistedGlobalSelections, setSessionGlobalSelections])
|
||||||
|
|
||||||
// Handle restoring a saved session
|
// Handle restoring a saved session
|
||||||
const handleRestoreSession = useCallback((session: ImportSession) => {
|
const handleRestoreSession = useCallback((session: ImportSession) => {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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 { 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';
|
import type { ImportSessionData, SerializedValidationState } from '@/types/importSession';
|
||||||
@@ -73,6 +74,9 @@ 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
|
// Import session autosave
|
||||||
const { markDirty } = useImportAutosave({
|
const { markDirty } = useImportAutosave({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -96,9 +100,10 @@ export const ValidationContainer = ({
|
|||||||
return {
|
return {
|
||||||
current_step: 'validation',
|
current_step: 'validation',
|
||||||
data: state.rows,
|
data: state.rows,
|
||||||
|
global_selections: getGlobalSelections(),
|
||||||
validation_state: serializedValidationState,
|
validation_state: serializedValidationState,
|
||||||
};
|
};
|
||||||
}, []),
|
}, [getGlobalSelections]),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to store changes to trigger autosave
|
// Subscribe to store changes to trigger autosave
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1491,6 +1492,8 @@ VirtualRow.displayName = 'VirtualRow';
|
|||||||
*/
|
*/
|
||||||
const HeaderCheckbox = memo(() => {
|
const HeaderCheckbox = memo(() => {
|
||||||
const filters = useFilters();
|
const filters = useFilters();
|
||||||
|
// Subscribe to row count to recalculate when rows are added/removed
|
||||||
|
const rowCount = useRowCount();
|
||||||
|
|
||||||
// Compute which rows are visible based on current filters
|
// Compute which rows are visible based on current filters
|
||||||
const { visibleRowIds, visibleCount } = useMemo(() => {
|
const { visibleRowIds, visibleCount } = useMemo(() => {
|
||||||
@@ -1525,7 +1528,7 @@ const HeaderCheckbox = memo(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { visibleRowIds: ids, visibleCount: ids.size };
|
return { visibleRowIds: ids, visibleCount: ids.size };
|
||||||
}, [filters.searchText, filters.showErrorsOnly]);
|
}, [filters.searchText, filters.showErrorsOnly, rowCount]);
|
||||||
|
|
||||||
// Check selection state against visible rows only
|
// Check selection state against visible rows only
|
||||||
const selectedRows = useValidationStore((state) => state.selectedRows);
|
const selectedRows = useValidationStore((state) => state.selectedRows);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,6 +55,7 @@ interface MultilineInputProps {
|
|||||||
const MultilineInputComponent = ({
|
const MultilineInputComponent = ({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
|
productIndex,
|
||||||
isValidating,
|
isValidating,
|
||||||
errors,
|
errors,
|
||||||
onChange: _onChange, // Unused - onBlur handles both update and validation
|
onChange: _onChange, // Unused - onBlur handles both update and validation
|
||||||
@@ -69,6 +70,8 @@ const MultilineInputComponent = ({
|
|||||||
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
||||||
const [editedSuggestion, setEditedSuggestion] = useState('');
|
const [editedSuggestion, setEditedSuggestion] = useState('');
|
||||||
const [popoverWidth, setPopoverWidth] = useState(400);
|
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
|
||||||
@@ -77,6 +80,14 @@ const MultilineInputComponent = ({
|
|||||||
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
// Tracks the value when popover opened, to detect actual changes
|
// Tracks the value when popover opened, to detect actual changes
|
||||||
const initialEditValueRef = useRef('');
|
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);
|
||||||
@@ -134,14 +145,62 @@ const MultilineInputComponent = ({
|
|||||||
}
|
}
|
||||||
}, [popoverOpen, editValue, autoResizeTextarea]);
|
}, [popoverOpen, editValue, autoResizeTextarea]);
|
||||||
|
|
||||||
// Auto-resize suggestion textarea when expanded or value changes
|
// Auto-resize suggestion textarea when expanded/visible or value changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (aiSuggestionExpanded) {
|
if (aiSuggestionExpanded || (popoverOpen && hasAiSuggestion)) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
autoResizeTextarea(suggestionTextareaRef.current);
|
autoResizeTextarea(suggestionTextareaRef.current);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [aiSuggestionExpanded, editedSuggestion, autoResizeTextarea]);
|
}, [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(() => {
|
||||||
@@ -164,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();
|
||||||
@@ -352,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: popoverWidth }}
|
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"
|
||||||
@@ -371,98 +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}
|
ref={mainTextareaRef}
|
||||||
value={editValue}
|
value={editValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onWheel={handleTextareaWheel}
|
onWheel={handleTextareaWheel}
|
||||||
className="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 min-h-[65px]"
|
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
|
|
||||||
ref={suggestionTextareaRef}
|
|
||||||
value={editedSuggestion}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditedSuggestion(e.target.value);
|
|
||||||
autoResizeTextarea(e.target);
|
|
||||||
}}
|
|
||||||
onWheel={handleTextareaWheel}
|
|
||||||
className="min-h-[80px] 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}
|
|
||||||
>
|
|
||||||
Ignore
|
|
||||||
</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 {
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
ImportSessionData,
|
ImportSessionData,
|
||||||
ImportSessionContextValue,
|
ImportSessionContextValue,
|
||||||
} from '@/types/importSession';
|
} from '@/types/importSession';
|
||||||
|
import { useValidationStore } from '@/components/product-import/steps/ValidationStep/store/validationStore';
|
||||||
|
|
||||||
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 seconds
|
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 seconds
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ const defaultContext: ImportSessionContextValue = {
|
|||||||
deleteSession: async () => {},
|
deleteSession: async () => {},
|
||||||
setDataGetter: () => {},
|
setDataGetter: () => {},
|
||||||
getSuggestedSessionName: () => null,
|
getSuggestedSessionName: () => null,
|
||||||
|
setGlobalSelections: () => {},
|
||||||
|
getGlobalSelections: () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImportSessionContext = createContext<ImportSessionContextValue>(defaultContext);
|
export const ImportSessionContext = createContext<ImportSessionContextValue>(defaultContext);
|
||||||
@@ -65,9 +68,26 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [currentStep, setCurrentStep] = useState<'validation' | 'imageUpload'>('validation');
|
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)
|
// Ref to hold the data getter function (set by ValidationStep/ImageUploadStep)
|
||||||
const dataGetterRef = useRef<(() => ImportSessionData) | null>(null);
|
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
|
// Autosave timer ref
|
||||||
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -87,11 +107,57 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
|
|||||||
dataGetterRef.current = getter;
|
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;
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the session as having unsaved changes
|
* 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(() => {
|
const markDirty = useCallback(() => {
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
|
isDirtyRef.current = true;
|
||||||
|
|
||||||
// Schedule autosave
|
// Schedule autosave
|
||||||
if (autosaveTimerRef.current) {
|
if (autosaveTimerRef.current) {
|
||||||
@@ -99,59 +165,27 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
autosaveTimerRef.current = setTimeout(() => {
|
autosaveTimerRef.current = setTimeout(() => {
|
||||||
// Trigger autosave if we have a data getter and user
|
if (dataGetterRef.current && userIdRef.current) {
|
||||||
if (dataGetterRef.current && user?.id) {
|
|
||||||
const data = dataGetterRef.current();
|
const data = dataGetterRef.current();
|
||||||
performAutosave(data);
|
performSave(data);
|
||||||
}
|
}
|
||||||
}, AUTOSAVE_DEBOUNCE_MS);
|
}, AUTOSAVE_DEBOUNCE_MS);
|
||||||
}, [user?.id]);
|
}, [performSave]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform autosave operation
|
|
||||||
*/
|
|
||||||
const performAutosave = useCallback(async (data: ImportSessionData) => {
|
|
||||||
if (!user?.id || isSaving) return;
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
if (sessionId && sessionName) {
|
|
||||||
// Update existing named session
|
|
||||||
await updateSession(sessionId, data);
|
|
||||||
} else if (sessionId && !sessionName) {
|
|
||||||
// Update existing unnamed session
|
|
||||||
await updateSession(sessionId, data);
|
|
||||||
} else {
|
|
||||||
// Create/update unnamed autosave session
|
|
||||||
const result = await autosaveSession({
|
|
||||||
user_id: user.id,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
// Store the session ID for future updates
|
|
||||||
setSessionId(result.id);
|
|
||||||
}
|
|
||||||
setLastSaved(new Date());
|
|
||||||
setIsDirty(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Autosave failed:', error);
|
|
||||||
// Don't clear dirty flag - will retry on next change
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}, [user?.id, sessionId, sessionName, isSaving]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save current state (autosave or update named session)
|
* Save current state (autosave or update named session)
|
||||||
*/
|
*/
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
if (!dataGetterRef.current || !user?.id) return;
|
if (!dataGetterRef.current || !userIdRef.current) return;
|
||||||
|
|
||||||
const data = dataGetterRef.current();
|
const data = dataGetterRef.current();
|
||||||
await performAutosave(data);
|
await performSave(data);
|
||||||
}, [user?.id, performAutosave]);
|
}, [performSave]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force immediate save, bypassing debounce (used before closing)
|
* 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 () => {
|
const forceSave = useCallback(async () => {
|
||||||
// Clear any pending autosave timer
|
// Clear any pending autosave timer
|
||||||
@@ -160,12 +194,11 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
|
|||||||
autosaveTimerRef.current = null;
|
autosaveTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only save if dirty and we have data
|
if (!dataGetterRef.current || !userIdRef.current) return;
|
||||||
if (!isDirty || !dataGetterRef.current || !user?.id) return;
|
|
||||||
|
|
||||||
const data = dataGetterRef.current();
|
const data = dataGetterRef.current();
|
||||||
await performAutosave(data);
|
await performSave(data, true);
|
||||||
}, [isDirty, user?.id, performAutosave]);
|
}, [performSave]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a suggested session name based on data (Company - Line format)
|
* Get a suggested session name based on data (Company - Line format)
|
||||||
@@ -177,18 +210,32 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
|
|||||||
const data = dataGetterRef.current();
|
const data = dataGetterRef.current();
|
||||||
if (!data.data || data.data.length === 0) return null;
|
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
|
// Find the first row with both company and line
|
||||||
for (const row of data.data) {
|
for (const row of data.data) {
|
||||||
const company = row.company;
|
const company = row.company;
|
||||||
const line = row.line;
|
const line = row.line;
|
||||||
|
|
||||||
if (company && line) {
|
if (company && line) {
|
||||||
// Convert values to display strings
|
const companyStr = String(company).trim();
|
||||||
const companyStr = typeof company === 'string' ? company : String(company);
|
const lineStr = String(line).trim();
|
||||||
const lineStr = typeof line === 'string' ? line : String(line);
|
|
||||||
|
|
||||||
if (companyStr.trim() && lineStr.trim()) {
|
if (companyStr && lineStr) {
|
||||||
return `${companyStr} - ${lineStr}`;
|
return `${resolveCompany(companyStr)} - ${resolveLine(companyStr, lineStr)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,9 +244,9 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
|
|||||||
for (const row of data.data) {
|
for (const row of data.data) {
|
||||||
const company = row.company;
|
const company = row.company;
|
||||||
if (company) {
|
if (company) {
|
||||||
const companyStr = typeof company === 'string' ? company : String(company);
|
const companyStr = String(company).trim();
|
||||||
if (companyStr.trim()) {
|
if (companyStr) {
|
||||||
return companyStr;
|
return resolveCompany(companyStr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,46 +258,61 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save current state as a new named session
|
* 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> => {
|
const saveAsNamed = useCallback(async (name: string): Promise<ImportSession> => {
|
||||||
if (!dataGetterRef.current || !user?.id) {
|
if (!dataGetterRef.current || !userIdRef.current) {
|
||||||
throw new Error('Cannot save: no data or user');
|
throw new Error('Cannot save: no data or user');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
isSavingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const data = dataGetterRef.current();
|
const data = dataGetterRef.current();
|
||||||
const result = await createSession({
|
const existingId = sessionIdRef.current;
|
||||||
user_id: user.id,
|
let result: ImportSession;
|
||||||
name,
|
|
||||||
...data,
|
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
|
// Update context to track this named session
|
||||||
setSessionId(result.id);
|
setSessionId(result.id);
|
||||||
|
sessionIdRef.current = result.id;
|
||||||
setSessionName(name);
|
setSessionName(name);
|
||||||
|
sessionNameRef.current = name;
|
||||||
setLastSaved(new Date());
|
setLastSaved(new Date());
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
|
isDirtyRef.current = false;
|
||||||
// If we had an unnamed session before, it's now replaced by this named one
|
|
||||||
// The unnamed slot will be cleared when user starts a new import
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
isSavingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [user?.id]);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a session (called when restoring from saved sessions list)
|
* Load a session (called when restoring from saved sessions list)
|
||||||
*/
|
*/
|
||||||
const loadSession = useCallback((session: ImportSession) => {
|
const loadSession = useCallback((session: ImportSession) => {
|
||||||
setSessionId(session.id);
|
setSessionId(session.id);
|
||||||
|
sessionIdRef.current = session.id;
|
||||||
setSessionName(session.name);
|
setSessionName(session.name);
|
||||||
|
sessionNameRef.current = session.name;
|
||||||
setCurrentStep(session.current_step);
|
setCurrentStep(session.current_step);
|
||||||
setLastSaved(new Date(session.updated_at));
|
setLastSaved(new Date(session.updated_at));
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
|
isDirtyRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,8 +323,11 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
|
|||||||
clearTimeout(autosaveTimerRef.current);
|
clearTimeout(autosaveTimerRef.current);
|
||||||
}
|
}
|
||||||
setSessionId(null);
|
setSessionId(null);
|
||||||
|
sessionIdRef.current = null;
|
||||||
setSessionName(null);
|
setSessionName(null);
|
||||||
|
sessionNameRef.current = null;
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
|
isDirtyRef.current = false;
|
||||||
setLastSaved(null);
|
setLastSaved(null);
|
||||||
setCurrentStep('validation');
|
setCurrentStep('validation');
|
||||||
dataGetterRef.current = null;
|
dataGetterRef.current = null;
|
||||||
@@ -272,17 +337,18 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
|
|||||||
* Delete the current session (called on successful submit)
|
* Delete the current session (called on successful submit)
|
||||||
*/
|
*/
|
||||||
const handleDeleteSession = useCallback(async () => {
|
const handleDeleteSession = useCallback(async () => {
|
||||||
if (!sessionId) return;
|
const id = sessionIdRef.current;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteSession(sessionId);
|
await deleteSession(id);
|
||||||
clearSession();
|
clearSession();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete session:', error);
|
console.error('Failed to delete session:', error);
|
||||||
// Clear local state anyway
|
// Clear local state anyway
|
||||||
clearSession();
|
clearSession();
|
||||||
}
|
}
|
||||||
}, [sessionId, clearSession]);
|
}, [clearSession]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImportSessionContext.Provider
|
<ImportSessionContext.Provider
|
||||||
@@ -303,6 +369,8 @@ export function ImportSessionProvider({ children }: ImportSessionProviderProps)
|
|||||||
deleteSession: handleDeleteSession,
|
deleteSession: handleDeleteSession,
|
||||||
setDataGetter,
|
setDataGetter,
|
||||||
getSuggestedSessionName,
|
getSuggestedSessionName,
|
||||||
|
setGlobalSelections,
|
||||||
|
getGlobalSelections,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -61,13 +61,15 @@ export function useImportAutosave({
|
|||||||
getSessionDataRef.current = getSessionData;
|
getSessionDataRef.current = getSessionData;
|
||||||
}, [getSessionData]);
|
}, [getSessionData]);
|
||||||
|
|
||||||
// Register the data getter with the context
|
// Register the data getter with the context and trigger initial autosave
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
setDataGetter(() => getSessionDataRef.current());
|
setDataGetter(() => getSessionDataRef.current());
|
||||||
setCurrentStep(step);
|
setCurrentStep(step);
|
||||||
|
// Trigger initial autosave so the session is persisted immediately
|
||||||
|
contextMarkDirty();
|
||||||
}
|
}
|
||||||
}, [enabled, setDataGetter, setCurrentStep, step]);
|
}, [enabled, setDataGetter, setCurrentStep, step, contextMarkDirty]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the session as dirty (has unsaved changes).
|
* Mark the session as dirty (has unsaved changes).
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ export interface ImportSessionContextValue {
|
|||||||
setDataGetter: (getter: () => ImportSessionData) => void;
|
setDataGetter: (getter: () => ImportSessionData) => void;
|
||||||
/** Get a suggested session name based on data (Company - Line) */
|
/** Get a suggested session name based on data (Company - Line) */
|
||||||
getSuggestedSessionName: () => string | null;
|
getSuggestedSessionName: () => string | null;
|
||||||
|
/** Store global selections for inclusion in autosave data */
|
||||||
|
setGlobalSelections: (selections: GlobalSelections | undefined) => void;
|
||||||
|
/** Get stored global selections */
|
||||||
|
getGlobalSelections: () => GlobalSelections | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -136,6 +140,7 @@ export interface ImportSessionCreateRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportSessionUpdateRequest {
|
export interface ImportSessionUpdateRequest {
|
||||||
|
name?: string;
|
||||||
current_step: 'validation' | 'imageUpload';
|
current_step: 'validation' | 'imageUpload';
|
||||||
data: RowData[];
|
data: RowData[];
|
||||||
product_images?: ProductImageSortable[];
|
product_images?: ProductImageSortable[];
|
||||||
|
|||||||
Reference in New Issue
Block a user