From f9e8c9265edc39e29f9190ce2a682b8235e3c2fa Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 29 Jan 2026 16:03:07 -0500 Subject: [PATCH] Add option to not hide submitted products for product import, rework description popover, fix steps --- CLAUDE.md | 1 + .../src/routes/import-sessions.js | 312 +++++++++--------- .../components/CloseConfirmationDialog.tsx | 232 +++++++------ .../components/ModalWrapper.tsx | 81 +++-- .../steps/ImageUploadStep/ImageUploadStep.tsx | 93 +++++- .../components/product-import/steps/Steps.tsx | 31 +- .../product-import/steps/UploadFlow.tsx | 7 +- .../components/ValidationContainer.tsx | 7 +- .../components/ValidationTable.tsx | 5 +- .../components/cells/ComboboxCell.tsx | 27 +- .../components/cells/MultilineInput.tsx | 266 ++++++++++----- .../components/cells/SelectCell.tsx | 26 +- .../hooks/useTemplateManagement.ts | 31 +- .../src/components/product-import/types.ts | 1 + .../src/contexts/ImportSessionContext.tsx | 204 ++++++++---- inventory/src/hooks/useImportAutosave.ts | 6 +- inventory/src/pages/Import.tsx | 200 +---------- inventory/src/types/importSession.ts | 5 + 18 files changed, 842 insertions(+), 693 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a963e90 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead. \ No newline at end of file diff --git a/inventory-server/src/routes/import-sessions.js b/inventory-server/src/routes/import-sessions.js index b35fa73..913cf68 100644 --- a/inventory-server/src/routes/import-sessions.js +++ b/inventory-server/src/routes/import-sessions.js @@ -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 +// IMPORTANT: This must be defined before /:id routes to avoid Express matching "autosave" as an :id router.put('/autosave', async (req, res) => { try { const { @@ -240,7 +118,7 @@ router.put('/autosave', async (req, res) => { global_selections = EXCLUDED.global_selections, validation_state = EXCLUDED.validation_state, updated_at = CURRENT_TIMESTAMP - RETURNING * + RETURNING id, user_id, name, current_step, created_at, updated_at `, [ user_id, 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) +// IMPORTANT: This must be defined before /:id routes router.delete('/autosave/:user_id', async (req, res) => { try { const { user_id } = req.params; @@ -295,7 +149,7 @@ router.delete('/autosave/:user_id', async (req, res) => { } 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] ); @@ -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 router.use((err, req, res, next) => { console.error('Import sessions route error:', err); diff --git a/inventory/src/components/product-import/components/CloseConfirmationDialog.tsx b/inventory/src/components/product-import/components/CloseConfirmationDialog.tsx index 386ee01..6050ae7 100644 --- a/inventory/src/components/product-import/components/CloseConfirmationDialog.tsx +++ b/inventory/src/components/product-import/components/CloseConfirmationDialog.tsx @@ -1,15 +1,15 @@ /** * CloseConfirmationDialog Component * - * Shown when user attempts to close the import modal. - * Offers options to save the session before closing. + * Single dialog shown when user attempts to close the import modal. + * Named sessions: Save & Exit or Cancel. + * Unnamed sessions: Keep (autosave), Save with Name (inline input), Discard, or Cancel. */ -import { useState } from 'react'; -import { Loader2, Save } from 'lucide-react'; +import { useState, useContext, useEffect } from 'react'; +import { Loader2, Save, Trash2, ArrowLeft } from 'lucide-react'; import { AlertDialog, - AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, @@ -21,9 +21,9 @@ import { } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { useImportSession } from '@/contexts/ImportSessionContext'; -import { toast } from 'sonner'; +import { AuthContext } from '@/contexts/AuthContext'; +import { deleteAutosaveSession, deleteSession as deleteSessionApi } from '@/services/importSessionApi'; interface CloseConfirmationDialogProps { open: boolean; @@ -38,83 +38,97 @@ export function CloseConfirmationDialog({ }: CloseConfirmationDialogProps) { const { sessionName, + sessionId, isDirty, forceSave, saveAsNamed, + clearSession, getSuggestedSessionName, - isSaving, } = useImportSession(); + const { user } = useContext(AuthContext); - const [showNameInput, setShowNameInput] = useState(false); const [name, setName] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [showNameInput, setShowNameInput] = useState(false); // Reset state when dialog opens - const handleOpenChange = (newOpen: boolean) => { - if (newOpen) { - // Pre-populate with suggested name when opening + useEffect(() => { + if (open) { const suggested = getSuggestedSessionName(); setName(suggested || ''); - setShowNameInput(false); 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); }; - // Handle "Save & Exit" for named sessions + // Save & Exit (for named sessions, or "Keep" for unnamed) const handleSaveAndExit = async () => { setSaving(true); try { await forceSave(); - toast.success('Session saved'); onConfirmClose(); } catch (err) { console.error('Failed to save:', err); - toast.error('Failed to save session'); + onConfirmClose(); } finally { setSaving(false); } }; - // Handle "Save As" for unnamed sessions - const handleSaveAs = async () => { + // Save with the entered name, then exit + const handleSaveWithName = async () => { const trimmedName = name.trim(); if (!trimmedName) { - setError('Please enter a name for the session'); + setError('Enter a name'); return; } - setSaving(true); try { await saveAsNamed(trimmedName); - toast.success('Session saved'); onConfirmClose(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save session'); + setError(err instanceof Error ? err.message : 'Failed to save'); } finally { setSaving(false); } }; - // Handle "Exit Without Saving" - still auto-saves as unnamed - const handleExitWithoutNaming = async () => { + // Discard session and exit + const handleDiscardAndExit = async () => { setSaving(true); try { - await forceSave(); + if (sessionId) { + await deleteSessionApi(sessionId); + } else if (user?.id) { + await deleteAutosaveSession(user.id); + } + clearSession(); onConfirmClose(); } catch (err) { - console.error('Failed to autosave:', err); - // Still close even if autosave fails + console.error('Failed to discard session:', err); + clearSession(); onConfirmClose(); } finally { setSaving(false); } }; - const isProcessing = saving || isSaving; - - // Session is already named + // --- Named session: simple save & exit --- if (sessionName) { return ( @@ -131,81 +145,12 @@ export function CloseConfirmationDialog({ Cancel - + - @@ -214,42 +159,89 @@ export function CloseConfirmationDialog({ ); } - // Initial state - unnamed session, ask what to do + // --- Unnamed session: all options in one view --- return ( - + - Exit Import + {showNameInput ? 'Save As...' : 'Exit Product Import'} - Your progress will be automatically saved. You can restore it later from the upload step - as "Previous Session", or save it with a name for easier reference. + {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.'} - + + {/* Inline name input - shown only when user clicks "Save with Name" */} + {showNameInput && ( + <> +
+ { setName(e.target.value); setError(null); }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !isProcessing && name.trim()) { + handleSaveWithName(); + } + }} + disabled={isProcessing} + className="w-[200px]" + autoFocus + /> + +
+ {error &&

{error}

} + + )} + {!showNameInput && ( + + + <> - Continue Editing + Continue Editing + - {isProcessing ? ( - <> - - Saving... - + ) : ( - 'Exit' + )} - - + Discard + + + + + +
)}
diff --git a/inventory/src/components/product-import/components/ModalWrapper.tsx b/inventory/src/components/product-import/components/ModalWrapper.tsx index 8f36893..a996901 100644 --- a/inventory/src/components/product-import/components/ModalWrapper.tsx +++ b/inventory/src/components/product-import/components/ModalWrapper.tsx @@ -1,17 +1,11 @@ import type React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" import { Dialog, - DialogContent, - DialogOverlay, - DialogPortal, - DialogClose, } from "@/components/ui/dialog" -import { - AlertDialog, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog" +import { X } from "lucide-react" import { useRsi } from "../hooks/useRsi" -import { useState, useCallback } from "react" +import { useState, useCallback, useRef } from "react" import { CloseConfirmationDialog } from "./CloseConfirmationDialog" type Props = { @@ -23,55 +17,82 @@ type Props = { export const ModalWrapper = ({ children, isOpen, onClose }: Props) => { const { rtl } = useRsi() const [showCloseAlert, setShowCloseAlert] = useState(false) + // Guard: when we're programmatically closing, don't re-show the alert + const closingRef = useRef(false) - // Create a handler that resets scroll positions before closing - const handleClose = useCallback(() => { - // Reset all scroll positions in the dialog + // Called after user confirms close in the dialog + const handleConfirmClose = useCallback(() => { + // Dismiss the confirmation dialog + setShowCloseAlert(false) + + // Mark that we're intentionally closing so onOpenChange doesn't re-trigger the alert + closingRef.current = true + + // Reset scroll positions const scrollContainers = document.querySelectorAll('.overflow-auto, .overflow-scroll'); scrollContainers.forEach(container => { if (container instanceof HTMLElement) { - // Reset scroll position to top-left container.scrollTop = 0; container.scrollLeft = 0; } }); - // Call the original onClose handler + // Close the main dialog onClose(); + + // Reset the guard after a tick (after Radix fires onOpenChange) + requestAnimationFrame(() => { + closingRef.current = false + }) }, [onClose]); + // Radix fires this when something tries to change the dialog's open state + // (e.g. focus loss, internal close). We intercept to show our confirmation instead. + const handleDialogOpenChange = useCallback((open: boolean) => { + if (!open && !closingRef.current) { + // Something is trying to close the dialog — show confirmation + setShowCloseAlert(true) + } + }, []) + return ( <> - setShowCloseAlert(true)} modal> - - - + + + { e.preventDefault() setShowCloseAlert(true) }} onPointerDownOutside={(e) => e.preventDefault()} - className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] h-[calc(100%-2rem)] max-w-[100vw] max-h-[100vh] flex flex-col overflow-hidden rounded-lg border bg-background p-0 shadow-lg sm:w-[calc(100%-3rem)] sm:h-[calc(100%-3rem)] md:w-[calc(100%-4rem)] md:h-[calc(100%-4rem)]" + className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] h-[calc(100%-2rem)] max-w-[100vw] max-h-[100vh] flex flex-col overflow-hidden rounded-lg border bg-background p-0 shadow-lg sm:w-[calc(100%-3rem)] sm:h-[calc(100%-3rem)] md:w-[calc(100%-4rem)] md:h-[calc(100%-4rem)]" > - - - { - e.preventDefault() - setShowCloseAlert(true) - }} /> - - +
{children}
-
-
+ +
) diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx index 041da0b..0fefe33 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx @@ -24,6 +24,16 @@ import { useBulkImageUpload } from "./hooks/useBulkImageUpload"; import { useUrlImageUpload } from "./hooks/useUrlImageUpload"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { AuthContext } from "@/contexts/AuthContext"; import { useImportAutosave } from "@/hooks/useImportAutosave"; import { useImportSession } from "@/contexts/ImportSessionContext"; @@ -52,9 +62,11 @@ export const ImageUploadStep = ({ const [targetEnvironment, setTargetEnvironment] = useState("prod"); const [useTestDataSource, setUseTestDataSource] = useState(false); const [skipApiSubmission, setSkipApiSubmission] = useState(false); + const [showNewProduct, setShowNewProduct] = useState(false); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); - // Import session context for cleanup on submit - const { deleteSession: deleteImportSession } = useImportSession(); + // Import session context for cleanup on submit and global selections + const { deleteSession: deleteImportSession, getGlobalSelections } = useImportSession(); // Use our hook for product images initialization const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data); @@ -106,14 +118,21 @@ export const ImageUploadStep = ({ current_step: 'imageUpload', data: data as any[], // Product data 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(() => { - markDirty(); - }, [productImages, markDirty]); + if (prevProductImagesRef.current !== productImages) { + prevProductImagesRef.current = productImages; + markDirtyRef.current(); + } + }, [productImages]); // Set up sensors for drag and drop with enhanced configuration const sensors = useSensors( @@ -196,14 +215,17 @@ export const ImageUploadStep = ({ return { ...product, // Store as comma-separated string to ensure compatibility - product_images: images.join(',') + product_images: images.join(','), + // Add show_new_product flag if enabled + ...(showNewProduct && { show_new_product: true }) }; }); - + const submitOptions: SubmitOptions = { targetEnvironment, useTestDataSource, skipApiSubmission, + showNewProduct, }; await onSubmit(updatedData, file, submitOptions); @@ -221,10 +243,19 @@ export const ImageUploadStep = ({ } finally { setIsSubmitting(false); } - }, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission, deleteImportSession]); + }, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission, showNewProduct, deleteImportSession]); return ( -
+
+ {/* Full-screen loading overlay during submit */} + {isSubmitting && ( +
+ +
Submitting products...
+
Please wait while your import is being processed
+
+ )} + {/* Header - fixed at top */}
@@ -335,6 +366,24 @@ export const ImageUploadStep = ({ )}
+
+ { + if (checked) { + setShowConfirmDialog(true); + } else { + setShowNewProduct(false); + } + }} + /> +
+ +
+
{hasDebugPermission && (
{!skipApiSubmission && ( @@ -389,6 +438,30 @@ export const ImageUploadStep = ({
+ + + + + Show products immediately? + + 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. + + + + setShowConfirmDialog(false)}> + Cancel + + { + setShowNewProduct(true); + setShowConfirmDialog(false); + }} + > + Yes, show immediately + + + +
); }; diff --git a/inventory/src/components/product-import/steps/Steps.tsx b/inventory/src/components/product-import/steps/Steps.tsx index 49dbd77..2106d74 100644 --- a/inventory/src/components/product-import/steps/Steps.tsx +++ b/inventory/src/components/product-import/steps/Steps.tsx @@ -1,32 +1,26 @@ import { StepState, StepType, UploadFlow } from "./UploadFlow" import { useRsi } from "../hooks/useRsi" -import { useRef, useState, useEffect } from "react" +import { useRef, useState, useEffect, useContext } from "react" import { steps, stepTypeToStepIndex, stepIndexToStepType } from "../utils/steps" import { CgCheck } from "react-icons/cg" +import { ImportSessionContext } from "@/contexts/ImportSessionContext" const CheckIcon = ({ color }: { color: string }) => export const Steps = () => { - const { initialStepState, translations, isNavigationEnabled, isOpen } = useRsi() + const { initialStepState, translations, isNavigationEnabled } = useRsi() + const { clearSession } = useContext(ImportSessionContext) const initialStep = stepTypeToStepIndex(initialStepState?.type) const [activeStep, setActiveStep] = useState(initialStep) const [state, setState] = useState(initialStepState || { type: StepType.upload }) const history = useRef([]) - const prevIsOpen = useRef(isOpen) - // Reset state when dialog is reopened + // Clear previous session on mount so each open starts fresh. + // Steps unmounts when the dialog closes (Radix portal), so every open = fresh mount. useEffect(() => { - // Check if dialog was closed and is now open again - if (isOpen && !prevIsOpen.current) { - // Reset to initial state - setActiveStep(initialStep) - setState(initialStepState || { type: StepType.upload }) - history.current = [] - } - - // Update previous isOpen value - prevIsOpen.current = isOpen - }, [isOpen, initialStep, initialStepState]) + clearSession() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const onClickStep = (stepIndex: number) => { const type = stepIndexToStepType(stepIndex) @@ -59,13 +53,16 @@ export const Steps = () => { const onNext = (v: StepState) => { history.current.push(state) setState(v) - + if (v.type === StepType.validateData && 'isFromScratch' in v && v.isFromScratch) { // If starting from scratch, jump directly to the validation step const validationStepIndex = steps.indexOf('validationStep') setActiveStep(validationStepIndex) } else if (v.type !== StepType.selectSheet) { - setActiveStep(activeStep + 1) + // Use the step type to determine the correct index directly, + // rather than incrementing, to avoid stale closure issues + const targetIndex = stepTypeToStepIndex(v.type) + setActiveStep(targetIndex) } } diff --git a/inventory/src/components/product-import/steps/UploadFlow.tsx b/inventory/src/components/product-import/steps/UploadFlow.tsx index 4b6af54..21d0316 100644 --- a/inventory/src/components/product-import/steps/UploadFlow.tsx +++ b/inventory/src/components/product-import/steps/UploadFlow.tsx @@ -130,7 +130,12 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { ) // 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 const handleRestoreSession = useCallback((session: ImportSession) => { diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx index 3f583b2..ab538fe 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx @@ -30,6 +30,7 @@ import { SanityCheckDialog } from '../dialogs/SanityCheckDialog'; import { TemplateForm } from '@/components/templates/TemplateForm'; import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext'; import { useImportAutosave } from '@/hooks/useImportAutosave'; +import { useImportSession } from '@/contexts/ImportSessionContext'; import type { CleanRowData, RowData } from '../store/types'; import type { ProductForSanityCheck } from '../hooks/useSanityCheck'; import type { ImportSessionData, SerializedValidationState } from '@/types/importSession'; @@ -73,6 +74,9 @@ export const ValidationContainer = ({ // Handle UPC validation after copy-down operations on supplier/upc fields useCopyDownValidation(); + // Import session context for global selections + const { getGlobalSelections } = useImportSession(); + // Import session autosave const { markDirty } = useImportAutosave({ enabled: true, @@ -96,9 +100,10 @@ export const ValidationContainer = ({ return { current_step: 'validation', data: state.rows, + global_selections: getGlobalSelections(), validation_state: serializedValidationState, }; - }, []), + }, [getGlobalSelections]), }); // Subscribe to store changes to trigger autosave diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx index 0b8fd96..4f027df 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -27,6 +27,7 @@ import { useValidationStore } from '../store/validationStore'; import { useFields, useFilters, + useRowCount, } from '../store/selectors'; // NOTE: We intentionally do NOT import useValidationActions or useProductLines here! // Those hooks subscribe to global state (rows, errors, caches) which would cause @@ -1491,6 +1492,8 @@ VirtualRow.displayName = 'VirtualRow'; */ const HeaderCheckbox = memo(() => { 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 const { visibleRowIds, visibleCount } = useMemo(() => { @@ -1525,7 +1528,7 @@ const HeaderCheckbox = memo(() => { }); return { visibleRowIds: ids, visibleCount: ids.size }; - }, [filters.searchText, filters.showErrorsOnly]); + }, [filters.searchText, filters.showErrorsOnly, rowCount]); // Check selection state against visible rows only const selectedRows = useValidationStore((state) => state.selectedRows); diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx index 8ca64cb..52cb085 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx @@ -10,7 +10,7 @@ * This dramatically improves performance for 100+ option lists. */ -import { useState, useCallback, useRef, memo } from 'react'; +import { useState, useCallback, useRef, useMemo, memo } from 'react'; import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -59,6 +59,7 @@ const ComboboxCellComponent = ({ const [open, setOpen] = useState(false); const [isLoadingOptions, setIsLoadingOptions] = useState(false); const hasFetchedRef = useRef(false); + const scrollContainerRef = useRef(null); // Get store state for coordinating with popover close behavior const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); @@ -78,6 +79,10 @@ const ComboboxCellComponent = ({ if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) { return; } + // Reset scroll position when opening + if (isOpen && scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } setOpen(isOpen); if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) { hasFetchedRef.current = true; @@ -90,6 +95,13 @@ const ComboboxCellComponent = ({ [onFetchOptions, options.length, cellPopoverClosedAt] ); + // Reset scroll position when search filters the list + const handleSearchChange = useCallback(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }, []); + // Handle selection const handleSelect = useCallback( (selectedValue: string) => { @@ -105,6 +117,11 @@ const ComboboxCellComponent = ({ e.currentTarget.scrollTop += e.deltaY; }, []); + // Sort options alphabetically by label for consistent display + const sortedOptions = useMemo(() => { + return [...options].sort((a, b) => a.label.localeCompare(b.label)); + }, [options]); + return (
@@ -130,7 +147,10 @@ const ComboboxCellComponent = ({ - + {isLoadingOptions ? (
@@ -140,11 +160,12 @@ const ComboboxCellComponent = ({ <> No {field.label.toLowerCase()} found.
- {options.map((option) => ( + {sortedOptions.map((option) => ( (undefined); + const resizeContainerRef = useRef(null); const cellRef = useRef(null); const preventReopenRef = useRef(false); // Tracks intentional closes (close button, accept/dismiss) vs click-outside closes @@ -77,6 +80,14 @@ const MultilineInputComponent = ({ const suggestionTextareaRef = useRef(null); // Tracks the value when popover opened, to detect actual changes const initialEditValueRef = useRef(''); + // Ref for the right-side header+issues area to measure its height for left-side spacer + const aiHeaderRef = useRef(null); + const [aiHeaderHeight, setAiHeaderHeight] = useState(0); + + // Get the product name for this row from the store + const productName = useValidationStore( + (s) => s.rows.find((row) => row.__index === productIndex)?.name as string | undefined + ); // Get store state and actions for coordinating popover close behavior across cells const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); @@ -134,14 +145,62 @@ const MultilineInputComponent = ({ } }, [popoverOpen, editValue, autoResizeTextarea]); - // Auto-resize suggestion textarea when expanded or value changes + // Auto-resize suggestion textarea when expanded/visible or value changes useEffect(() => { - if (aiSuggestionExpanded) { + if (aiSuggestionExpanded || (popoverOpen && hasAiSuggestion)) { requestAnimationFrame(() => { 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) const wasPopoverRecentlyClosed = useCallback(() => { @@ -164,7 +223,7 @@ const MultilineInputComponent = ({ preventReopenRef.current = false; return; } - + // Block opening if another popover was just closed if (wasPopoverRecentlyClosed()) { e.preventDefault(); @@ -352,14 +411,30 @@ const MultilineInputComponent = ({ { + // 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%)'; + } + } + }} > -
+
{/* Close button */}