diff --git a/inventory-server/migrations/001_create_import_sessions.sql b/inventory-server/migrations/001_create_import_sessions.sql new file mode 100644 index 0000000..5d6dd77 --- /dev/null +++ b/inventory-server/migrations/001_create_import_sessions.sql @@ -0,0 +1,29 @@ +-- Migration: Create import_sessions table +-- Run this against your PostgreSQL database + +CREATE TABLE IF NOT EXISTS import_sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + name VARCHAR(255), -- NULL for unnamed/autosave sessions + current_step VARCHAR(50) NOT NULL, -- 'validation' | 'imageUpload' + data JSONB NOT NULL, -- Product rows + product_images JSONB, -- Image assignments + global_selections JSONB, -- Supplier, company, line, subline + validation_state JSONB, -- Errors, UPC status, generated item numbers + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Ensure only one unnamed session per user (autosave slot) +CREATE UNIQUE INDEX IF NOT EXISTS idx_unnamed_session_per_user + ON import_sessions (user_id) + WHERE name IS NULL; + +-- Index for fast user lookups +CREATE INDEX IF NOT EXISTS idx_import_sessions_user_id + ON import_sessions (user_id); + +-- Add comment for documentation +COMMENT ON TABLE import_sessions IS 'Stores in-progress product import sessions for users'; +COMMENT ON COLUMN import_sessions.name IS 'Session name - NULL indicates the single unnamed/autosave session per user'; +COMMENT ON COLUMN import_sessions.current_step IS 'Which step the user was on: validation or imageUpload'; diff --git a/inventory-server/src/routes/import-sessions.js b/inventory-server/src/routes/import-sessions.js new file mode 100644 index 0000000..b35fa73 --- /dev/null +++ b/inventory-server/src/routes/import-sessions.js @@ -0,0 +1,325 @@ +const express = require('express'); +const router = express.Router(); + +// Get all import sessions for a user (named + unnamed) +router.get('/', async (req, res) => { + try { + const { user_id } = req.query; + + if (!user_id) { + return res.status(400).json({ error: 'user_id query parameter is required' }); + } + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT + id, + user_id, + name, + current_step, + jsonb_array_length(data) as row_count, + global_selections, + created_at, + updated_at + FROM import_sessions + WHERE user_id = $1 + ORDER BY + CASE WHEN name IS NULL THEN 0 ELSE 1 END, + updated_at DESC + `, [user_id]); + + res.json(result.rows); + } catch (error) { + console.error('Error fetching import sessions:', error); + res.status(500).json({ + error: 'Failed to fetch import sessions', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get session by ID +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM import_sessions + WHERE id = $1 + `, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Import session not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching import session:', error); + res.status(500).json({ + error: 'Failed to fetch import session', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// 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 +router.put('/autosave', async (req, res) => { + try { + const { + user_id, + current_step, + data, + product_images, + global_selections, + validation_state + } = req.body; + + // Validate required fields + if (!user_id) { + return res.status(400).json({ error: 'user_id is required' }); + } + if (!current_step) { + return res.status(400).json({ error: 'current_step is required' }); + } + if (!data || !Array.isArray(data)) { + return res.status(400).json({ error: 'data must be an array' }); + } + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Upsert: insert or update the unnamed session for this user + const result = await pool.query(` + INSERT INTO import_sessions ( + user_id, + name, + current_step, + data, + product_images, + global_selections, + validation_state + ) VALUES ($1, NULL, $2, $3, $4, $5, $6) + ON CONFLICT (user_id) WHERE name IS NULL + DO UPDATE SET + current_step = EXCLUDED.current_step, + data = EXCLUDED.data, + product_images = EXCLUDED.product_images, + global_selections = EXCLUDED.global_selections, + validation_state = EXCLUDED.validation_state, + updated_at = CURRENT_TIMESTAMP + RETURNING * + `, [ + user_id, + current_step, + JSON.stringify(data), + product_images ? JSON.stringify(product_images) : null, + global_selections ? JSON.stringify(global_selections) : null, + validation_state ? JSON.stringify(validation_state) : null + ]); + + res.json(result.rows[0]); + } catch (error) { + console.error('Error autosaving import session:', error); + res.status(500).json({ + error: 'Failed to autosave import session', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Delete 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) +router.delete('/autosave/:user_id', async (req, res) => { + try { + const { user_id } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query( + 'DELETE FROM import_sessions WHERE user_id = $1 AND name IS NULL RETURNING *', + [user_id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'No autosave session found for user' }); + } + + res.json({ message: 'Autosave session deleted successfully' }); + } catch (error) { + console.error('Error deleting autosave session:', error); + res.status(500).json({ + error: 'Failed to delete autosave session', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Error handling middleware +router.use((err, req, res, next) => { + console.error('Import sessions route error:', err); + res.status(500).json({ + error: 'Internal server error', + details: err.message + }); +}); + +module.exports = router; diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index bb7e8a8..f6847cd 100644 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -23,6 +23,7 @@ const categoriesAggregateRouter = require('./routes/categoriesAggregate'); const vendorsAggregateRouter = require('./routes/vendorsAggregate'); const brandsAggregateRouter = require('./routes/brandsAggregate'); const htsLookupRouter = require('./routes/hts-lookup'); +const importSessionsRouter = require('./routes/import-sessions'); // Get the absolute path to the .env file const envPath = '/var/www/html/inventory/.env'; @@ -130,6 +131,7 @@ async function startServer() { app.use('/api/ai-prompts', aiPromptsRouter); app.use('/api/reusable-images', reusableImagesRouter); app.use('/api/hts-lookup', htsLookupRouter); + app.use('/api/import-sessions', importSessionsRouter); // Basic health check route app.get('/health', (req, res) => { diff --git a/inventory/src/components/product-import/ReactSpreadsheetImport.tsx b/inventory/src/components/product-import/ReactSpreadsheetImport.tsx index 4b1a74f..f7acda6 100644 --- a/inventory/src/components/product-import/ReactSpreadsheetImport.tsx +++ b/inventory/src/components/product-import/ReactSpreadsheetImport.tsx @@ -5,6 +5,7 @@ import { Providers } from "./components/Providers" import type { RsiProps } from "./types" import { ModalWrapper } from "./components/ModalWrapper" import { translations } from "./translationsRSIProps" +import { ImportSessionProvider } from "@/contexts/ImportSessionContext" // Simple empty theme placeholder export const defaultTheme = {} @@ -29,10 +30,12 @@ export const ReactSpreadsheetImport = (propsWithoutDefaults: R props.translations !== translations ? merge(translations, props.translations) : translations return ( - - - - - + + + + + + + ) } diff --git a/inventory/src/components/product-import/components/CloseConfirmationDialog.tsx b/inventory/src/components/product-import/components/CloseConfirmationDialog.tsx new file mode 100644 index 0000000..386ee01 --- /dev/null +++ b/inventory/src/components/product-import/components/CloseConfirmationDialog.tsx @@ -0,0 +1,257 @@ +/** + * CloseConfirmationDialog Component + * + * Shown when user attempts to close the import modal. + * Offers options to save the session before closing. + */ + +import { useState } from 'react'; +import { Loader2, Save } from 'lucide-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogPortal, + AlertDialogOverlay, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useImportSession } from '@/contexts/ImportSessionContext'; +import { toast } from 'sonner'; + +interface CloseConfirmationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirmClose: () => void; +} + +export function CloseConfirmationDialog({ + open, + onOpenChange, + onConfirmClose, +}: CloseConfirmationDialogProps) { + const { + sessionName, + isDirty, + forceSave, + saveAsNamed, + getSuggestedSessionName, + isSaving, + } = useImportSession(); + + const [showNameInput, setShowNameInput] = useState(false); + const [name, setName] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Reset state when dialog opens + const handleOpenChange = (newOpen: boolean) => { + if (newOpen) { + // Pre-populate with suggested name when opening + const suggested = getSuggestedSessionName(); + setName(suggested || ''); + setShowNameInput(false); + setError(null); + } + onOpenChange(newOpen); + }; + + // Handle "Save & Exit" for named sessions + 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'); + } finally { + setSaving(false); + } + }; + + // Handle "Save As" for unnamed sessions + const handleSaveAs = async () => { + const trimmedName = name.trim(); + if (!trimmedName) { + setError('Please enter a name for the session'); + return; + } + + setSaving(true); + try { + await saveAsNamed(trimmedName); + toast.success('Session saved'); + onConfirmClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save session'); + } finally { + setSaving(false); + } + }; + + // Handle "Exit Without Saving" - still auto-saves as unnamed + const handleExitWithoutNaming = async () => { + setSaving(true); + try { + await forceSave(); + onConfirmClose(); + } catch (err) { + console.error('Failed to autosave:', err); + // Still close even if autosave fails + onConfirmClose(); + } finally { + setSaving(false); + } + }; + + const isProcessing = saving || isSaving; + + // Session is already named + if (sessionName) { + return ( + + + + + + Exit Import + + {isDirty + ? `Your session "${sessionName}" will be saved. You can restore it later from the upload step.` + : `Your session "${sessionName}" is saved. You can restore it later from the upload step.`} + + + + Cancel + + {isProcessing ? ( + <> + + Saving... + + ) : ( + 'Save & Exit' + )} + + + + + + ); + } + + // Session is unnamed - show option to name or exit + if (showNameInput) { + return ( + + + + + + + + Save Session + + + Enter a name for your session so you can find it later. + + +
+
+ + { + setName(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !isProcessing && name.trim()) { + handleSaveAs(); + } + }} + disabled={isProcessing} + autoFocus + /> + {error &&

{error}

} +
+
+ + + + +
+
+
+ ); + } + + // Initial state - unnamed session, ask what to do + return ( + + + + + + Exit 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. + + + + + Continue Editing + + + + {isProcessing ? ( + <> + + Saving... + + ) : ( + 'Exit' + )} + + + + + + ); +} diff --git a/inventory/src/components/product-import/components/ModalWrapper.tsx b/inventory/src/components/product-import/components/ModalWrapper.tsx index fbb4363..8f36893 100644 --- a/inventory/src/components/product-import/components/ModalWrapper.tsx +++ b/inventory/src/components/product-import/components/ModalWrapper.tsx @@ -8,19 +8,11 @@ import { } from "@/components/ui/dialog" import { AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, AlertDialogTrigger, - AlertDialogPortal, - AlertDialogOverlay, } from "@/components/ui/alert-dialog" import { useRsi } from "../hooks/useRsi" import { useState, useCallback } from "react" +import { CloseConfirmationDialog } from "./CloseConfirmationDialog" type Props = { children: React.ReactNode @@ -29,9 +21,9 @@ type Props = { } export const ModalWrapper = ({ children, isOpen, onClose }: Props) => { - const { rtl, translations } = useRsi() + const { rtl } = useRsi() const [showCloseAlert, setShowCloseAlert] = useState(false) - + // Create a handler that resets scroll positions before closing const handleClose = useCallback(() => { // Reset all scroll positions in the dialog @@ -43,11 +35,11 @@ export const ModalWrapper = ({ children, isOpen, onClose }: Props) => { container.scrollLeft = 0; } }); - + // Call the original onClose handler onClose(); }, [onClose]); - + return ( <> setShowCloseAlert(true)} modal> @@ -76,29 +68,11 @@ export const ModalWrapper = ({ children, isOpen, onClose }: Props) => { - - - - - - - {translations.alerts.confirmClose.headerTitle} - - - {translations.alerts.confirmClose.bodyText} - - - - setShowCloseAlert(false)}> - {translations.alerts.confirmClose.cancelButtonTitle} - - - {translations.alerts.confirmClose.exitButtonTitle} - - - - - + ) } diff --git a/inventory/src/components/product-import/components/SaveSessionDialog.tsx b/inventory/src/components/product-import/components/SaveSessionDialog.tsx new file mode 100644 index 0000000..bd3bb32 --- /dev/null +++ b/inventory/src/components/product-import/components/SaveSessionDialog.tsx @@ -0,0 +1,178 @@ +/** + * SaveSessionDialog Component + * + * Dialog for saving the current import session with a name. + */ + +import { useState, useEffect } from 'react'; +import { Loader2, Save } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useImportSession } from '@/contexts/ImportSessionContext'; +import { toast } from 'sonner'; + +interface SaveSessionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Optional suggested name to pre-populate the input */ + suggestedName?: string | null; + /** Optional callback after successful save */ + onSaved?: () => void; +} + +export function SaveSessionDialog({ open, onOpenChange, suggestedName, onSaved }: SaveSessionDialogProps) { + const { saveAsNamed, sessionName, getSuggestedSessionName } = useImportSession(); + const [name, setName] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [initialized, setInitialized] = useState(false); + + // Initialize name when dialog opens + useEffect(() => { + if (open && !initialized) { + // Priority: existing session name > provided suggested name > auto-generated from data + const initialName = sessionName || suggestedName || getSuggestedSessionName() || ''; + setName(initialName); + setInitialized(true); + } + // Reset initialized flag when dialog closes + if (!open) { + setInitialized(false); + } + }, [open, initialized, sessionName, suggestedName, getSuggestedSessionName]); + + const handleSave = async () => { + const trimmedName = name.trim(); + if (!trimmedName) { + setError('Please enter a name for the session'); + return; + } + + try { + setSaving(true); + setError(null); + await saveAsNamed(trimmedName); + toast.success('Session saved successfully'); + onOpenChange(false); + onSaved?.(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save session'); + } finally { + setSaving(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !saving && name.trim()) { + handleSave(); + } + }; + + return ( + + + + + + Save Session + + + Save your current progress with a name so you can restore it later. + + +
+
+ + { + setName(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + disabled={saving} + autoFocus + /> + {error && ( +

{error}

+ )} +
+
+ + + + +
+
+ ); +} + +/** + * SaveSessionButton Component + * + * A button that opens the save session dialog. + * Shows current save status. + */ +interface SaveSessionButtonProps { + className?: string; +} + +export function SaveSessionButton({ className }: SaveSessionButtonProps) { + const [dialogOpen, setDialogOpen] = useState(false); + const { isSaving, lastSaved, sessionName } = useImportSession(); + + return ( + <> +
+ {lastSaved && ( + + {sessionName ? `Saved as "${sessionName}"` : 'Auto-saved'}{' '} + {new Date(lastSaved).toLocaleTimeString()} + + )} + {isSaving && ( + + + Saving... + + )} + +
+ + + + ); +} diff --git a/inventory/src/components/product-import/components/SavedSessionsList.tsx b/inventory/src/components/product-import/components/SavedSessionsList.tsx new file mode 100644 index 0000000..ed07ba9 --- /dev/null +++ b/inventory/src/components/product-import/components/SavedSessionsList.tsx @@ -0,0 +1,240 @@ +/** + * SavedSessionsList Component + * + * Displays a list of saved import sessions on the upload step. + * Shows named sessions and the previous unnamed session (if exists). + */ + +import { useState, useEffect, useContext } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { Loader2, Trash2, RotateCcw, Clock, FileSpreadsheet } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { AuthContext } from '@/contexts/AuthContext'; +import { importSessionApi } from '@/services/importSessionApi'; +import type { ImportSessionListItem, ImportSession } from '@/types/importSession'; + +interface SavedSessionsListProps { + onRestore: (session: ImportSession) => void; +} + +export function SavedSessionsList({ onRestore }: SavedSessionsListProps) { + const { user } = useContext(AuthContext); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [restoringId, setRestoringId] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + // Fetch sessions on mount + useEffect(() => { + if (!user?.id) return; + + async function fetchSessions() { + try { + setLoading(true); + setError(null); + const result = await importSessionApi.list(user!.id); + setSessions(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load sessions'); + } finally { + setLoading(false); + } + } + + fetchSessions(); + }, [user?.id]); + + // Handle restore + const handleRestore = async (sessionItem: ImportSessionListItem) => { + try { + setRestoringId(sessionItem.id); + // Fetch full session data + const fullSession = await importSessionApi.get(sessionItem.id); + onRestore(fullSession); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to restore session'); + } finally { + setRestoringId(null); + } + }; + + // Handle delete + const handleDelete = async (id: number) => { + try { + setDeletingId(id); + await importSessionApi.delete(id); + setSessions(prev => prev.filter(s => s.id !== id)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete session'); + } finally { + setDeletingId(null); + setDeleteConfirmId(null); + } + }; + + // Separate unnamed (previous) session from named sessions + const unnamedSession = sessions.find(s => s.name === null); + const namedSessions = sessions.filter(s => s.name !== null); + + // Don't render anything if no sessions + if (!loading && sessions.length === 0) { + return null; + } + + return ( + <> + + + + + Saved Sessions + + + + {loading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : ( +
+ {/* Previous session (unnamed) */} + {unnamedSession && ( + handleRestore(unnamedSession)} + onDelete={() => setDeleteConfirmId(unnamedSession.id)} + /> + )} + + {/* Named sessions */} + {namedSessions.map(session => ( + handleRestore(session)} + onDelete={() => setDeleteConfirmId(session.id)} + /> + ))} +
+ )} +
+
+ + {/* Delete confirmation dialog */} + setDeleteConfirmId(null)}> + + + Delete Session? + + This will permanently delete this saved session. This action cannot be undone. + + + + Cancel + deleteConfirmId && handleDelete(deleteConfirmId)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + + ); +} + +interface SessionRowProps { + session: ImportSessionListItem; + isPrevious?: boolean; + isRestoring: boolean; + isDeleting: boolean; + onRestore: () => void; + onDelete: () => void; +} + +function SessionRow({ + session, + isPrevious, + isRestoring, + isDeleting, + onRestore, + onDelete, +}: SessionRowProps) { + const timeAgo = formatDistanceToNow(new Date(session.updated_at), { addSuffix: true }); + + return ( + +
+
+
+ {isPrevious ? ( + Previous session + ) : ( + session.name + )} +
+
+ + + {timeAgo} + + {session.row_count} product{session.row_count !== 1 ? 's' : ''} +
+
+
+ + +
+
+ ); +} diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx index 5a6fa46..041da0b 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { - DndContext, + DndContext, KeyboardSensor, PointerSensor, useSensor, @@ -25,7 +25,11 @@ import { useUrlImageUpload } from "./hooks/useUrlImageUpload"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { AuthContext } from "@/contexts/AuthContext"; +import { useImportAutosave } from "@/hooks/useImportAutosave"; +import { useImportSession } from "@/contexts/ImportSessionContext"; +import { SaveSessionButton } from "../../components/SaveSessionDialog"; import type { SubmitOptions } from "../../types"; +import type { ImportSessionData } from "@/types/importSession"; interface Props { data: Product[]; @@ -48,7 +52,10 @@ export const ImageUploadStep = ({ const [targetEnvironment, setTargetEnvironment] = useState("prod"); const [useTestDataSource, setUseTestDataSource] = useState(false); const [skipApiSubmission, setSkipApiSubmission] = useState(false); - + + // Import session context for cleanup on submit + const { deleteSession: deleteImportSession } = useImportSession(); + // Use our hook for product images initialization const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data); @@ -89,7 +96,25 @@ export const ImageUploadStep = ({ data, handleImageUpload }); - + + // Autosave hook for session persistence + const { markDirty } = useImportAutosave({ + enabled: true, + step: 'imageUpload', + getSessionData: useCallback((): ImportSessionData => { + return { + current_step: 'imageUpload', + data: data as any[], // Product data + product_images: productImages, + }; + }, [data, productImages]), + }); + + // Mark dirty when images change + useEffect(() => { + markDirty(); + }, [productImages, markDirty]); + // Set up sensors for drag and drop with enhanced configuration const sensors = useSensors( useSensor(PointerSensor, { @@ -182,22 +207,35 @@ export const ImageUploadStep = ({ }; await onSubmit(updatedData, file, submitOptions); + + // Delete the import session on successful submit + try { + await deleteImportSession(); + } catch (err) { + // Non-critical - log but don't fail the submission + console.warn('Failed to delete import session:', err); + } } catch (error) { console.error('Submit error:', error); toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsSubmitting(false); } - }, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission]); + }, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission, deleteImportSession]); return (
{/* Header - fixed at top */}
-

Add Product Images

-

- Drag images to reorder them or move them between products. -

+
+
+

Add Product Images

+

+ Drag images to reorder them or move them between products. +

+
+ +
{/* Content area - only this part scrolls */} diff --git a/inventory/src/components/product-import/steps/UploadFlow.tsx b/inventory/src/components/product-import/steps/UploadFlow.tsx index 992740a..4b6af54 100644 --- a/inventory/src/components/product-import/steps/UploadFlow.tsx +++ b/inventory/src/components/product-import/steps/UploadFlow.tsx @@ -16,6 +16,8 @@ import { Progress } from "@/components/ui/progress" import { useToast } from "@/hooks/use-toast" import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations" import { useValidationStore } from "./ValidationStep/store/validationStore" +import { useImportSession } from "@/contexts/ImportSessionContext" +import type { ImportSession } from "@/types/importSession" export enum StepType { upload = "upload", @@ -127,7 +129,37 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { : undefined ) + // Import session context for session restoration + const { loadSession } = useImportSession() + // Handle restoring a saved session + const handleRestoreSession = useCallback((session: ImportSession) => { + // Load the session into context + loadSession(session) + + // Update global selections if they exist + if (session.global_selections) { + setPersistedGlobalSelections(session.global_selections) + } + + // Navigate to the appropriate step with session data + if (session.current_step === 'imageUpload') { + onNext({ + type: StepType.imageUpload, + data: session.data, + file: new File([], "restored-session.xlsx"), + globalSelections: session.global_selections || undefined, + }) + } else { + // Default to validation step + onNext({ + type: StepType.validateDataNew, + data: session.data, + globalSelections: session.global_selections || undefined, + isFromScratch: true, // Treat restored sessions like "from scratch" for back navigation + }) + } + }, [loadSession, onNext]) switch (state.type) { case StepType.upload: @@ -165,6 +197,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { }); } }} + onRestoreSession={handleRestoreSession} /> ) case StepType.selectSheet: diff --git a/inventory/src/components/product-import/steps/UploadStep/UploadStep.tsx b/inventory/src/components/product-import/steps/UploadStep/UploadStep.tsx index ea5744f..208ad3a 100644 --- a/inventory/src/components/product-import/steps/UploadStep/UploadStep.tsx +++ b/inventory/src/components/product-import/steps/UploadStep/UploadStep.tsx @@ -1,19 +1,42 @@ import type XLSX from "xlsx" -import { useCallback, useState } from "react" +import { useCallback, useState, useContext } from "react" import { useRsi } from "../../hooks/useRsi" import { DropZone } from "./components/DropZone" import { StepType } from "../UploadFlow" import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" +import { AuthContext } from "@/contexts/AuthContext" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { Bug } from "lucide-react" +import { v4 as uuidv4 } from "uuid" +import { SavedSessionsList } from "../../components/SavedSessionsList" +import type { ImportSession } from "@/types/importSession" type UploadProps = { onContinue: (data: XLSX.WorkBook, file: File) => Promise setInitialState?: (state: { type: StepType; data: any[]; isFromScratch?: boolean }) => void + onRestoreSession?: (session: ImportSession) => void } -export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => { +export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: UploadProps) => { const [isLoading, setIsLoading] = useState(false) const { translations } = useRsi() + const { user } = useContext(AuthContext) + const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug")) + + // Debug import state + const [debugDialogOpen, setDebugDialogOpen] = useState(false) + const [debugJsonInput, setDebugJsonInput] = useState("") + const [debugError, setDebugError] = useState(null) const handleOnContinue = useCallback( async (data: XLSX.WorkBook, file: File) => { @@ -29,11 +52,63 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => { setInitialState({ type: StepType.validateData, data: [{}], isFromScratch: true }) } }, [setInitialState]) + + const handleDebugImport = useCallback(() => { + setDebugError(null) + try { + const parsed = JSON.parse(debugJsonInput) + + // Handle both array and object with products property + let products: any[] = Array.isArray(parsed) ? parsed : parsed.products + + if (!Array.isArray(products) || products.length === 0) { + setDebugError("JSON must be an array of products or an object with a 'products' array") + return + } + + // Add __index to each row if not present (required for validation step) + const dataWithIndex = products.map((row: any) => ({ + ...row, + __index: row.__index || uuidv4() + })) + + if (setInitialState) { + setInitialState({ + type: StepType.validateData, + data: dataWithIndex, + isFromScratch: true + }) + } + + setDebugDialogOpen(false) + setDebugJsonInput("") + } catch (e) { + setDebugError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`) + } + }, [debugJsonInput, setInitialState]) return (
+

{translations.uploadStep.title}

- + {hasDebugPermission && ( + <> + + +
+ +
+ + )} +
@@ -45,8 +120,8 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
-
-
+ + {/* Saved sessions list */} + {onRestoreSession && ( + + )}
+ + + + + + + Debug: Import JSON Data + + + Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step. + + +
+
+ +