Add import session save/restore

This commit is contained in:
2026-01-27 21:08:44 -05:00
parent 11d0555eeb
commit ee2f314775
18 changed files with 2013 additions and 56 deletions

View File

@@ -0,0 +1,29 @@
-- Migration: Create import_sessions table
-- Run this against your PostgreSQL database
CREATE TABLE IF NOT EXISTS import_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
name VARCHAR(255), -- NULL for unnamed/autosave sessions
current_step VARCHAR(50) NOT NULL, -- 'validation' | 'imageUpload'
data JSONB NOT NULL, -- Product rows
product_images JSONB, -- Image assignments
global_selections JSONB, -- Supplier, company, line, subline
validation_state JSONB, -- Errors, UPC status, generated item numbers
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Ensure only one unnamed session per user (autosave slot)
CREATE UNIQUE INDEX IF NOT EXISTS idx_unnamed_session_per_user
ON import_sessions (user_id)
WHERE name IS NULL;
-- Index for fast user lookups
CREATE INDEX IF NOT EXISTS idx_import_sessions_user_id
ON import_sessions (user_id);
-- Add comment for documentation
COMMENT ON TABLE import_sessions IS 'Stores in-progress product import sessions for users';
COMMENT ON COLUMN import_sessions.name IS 'Session name - NULL indicates the single unnamed/autosave session per user';
COMMENT ON COLUMN import_sessions.current_step IS 'Which step the user was on: validation or imageUpload';

View File

@@ -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;

View File

@@ -23,6 +23,7 @@ const categoriesAggregateRouter = require('./routes/categoriesAggregate');
const vendorsAggregateRouter = require('./routes/vendorsAggregate'); const vendorsAggregateRouter = require('./routes/vendorsAggregate');
const brandsAggregateRouter = require('./routes/brandsAggregate'); const brandsAggregateRouter = require('./routes/brandsAggregate');
const htsLookupRouter = require('./routes/hts-lookup'); const htsLookupRouter = require('./routes/hts-lookup');
const importSessionsRouter = require('./routes/import-sessions');
// Get the absolute path to the .env file // Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env'; const envPath = '/var/www/html/inventory/.env';
@@ -130,6 +131,7 @@ async function startServer() {
app.use('/api/ai-prompts', aiPromptsRouter); app.use('/api/ai-prompts', aiPromptsRouter);
app.use('/api/reusable-images', reusableImagesRouter); app.use('/api/reusable-images', reusableImagesRouter);
app.use('/api/hts-lookup', htsLookupRouter); app.use('/api/hts-lookup', htsLookupRouter);
app.use('/api/import-sessions', importSessionsRouter);
// Basic health check route // Basic health check route
app.get('/health', (req, res) => { app.get('/health', (req, res) => {

View File

@@ -5,6 +5,7 @@ import { Providers } from "./components/Providers"
import type { RsiProps } from "./types" import type { RsiProps } from "./types"
import { ModalWrapper } from "./components/ModalWrapper" import { ModalWrapper } from "./components/ModalWrapper"
import { translations } from "./translationsRSIProps" import { translations } from "./translationsRSIProps"
import { ImportSessionProvider } from "@/contexts/ImportSessionContext"
// Simple empty theme placeholder // Simple empty theme placeholder
export const defaultTheme = {} export const defaultTheme = {}
@@ -29,10 +30,12 @@ export const ReactSpreadsheetImport = <T extends string>(propsWithoutDefaults: R
props.translations !== translations ? merge(translations, props.translations) : translations props.translations !== translations ? merge(translations, props.translations) : translations
return ( return (
<Providers rsiValues={{ ...props, translations: mergedTranslations }}> <ImportSessionProvider>
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}> <Providers rsiValues={{ ...props, translations: mergedTranslations }}>
<Steps /> <ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
</ModalWrapper> <Steps />
</Providers> </ModalWrapper>
</Providers>
</ImportSessionProvider>
) )
} }

View File

@@ -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<string | null>(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 (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className="z-[1500]">
<AlertDialogHeader>
<AlertDialogTitle>Exit Import</AlertDialogTitle>
<AlertDialogDescription>
{isDirty
? `Your session "${sessionName}" will be saved. You can restore it later from the upload step.`
: `Your session "${sessionName}" is saved. You can restore it later from the upload step.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isProcessing}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSaveAndExit} disabled={isProcessing}>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
'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>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
);
}
// Initial state - unnamed session, ask what to do
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className="z-[1500]">
<AlertDialogHeader>
<AlertDialogTitle>Exit Import</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
<AlertDialogCancel disabled={isProcessing} className="mt-0">
Continue Editing
</AlertDialogCancel>
<Button
variant="outline"
onClick={() => setShowNameInput(true)}
disabled={isProcessing}
>
<Save className="h-4 w-4 mr-2" />
Save with Name
</Button>
<AlertDialogAction onClick={handleExitWithoutNaming} disabled={isProcessing}>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Exit'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
);
}

View File

@@ -8,19 +8,11 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
AlertDialogPortal,
AlertDialogOverlay,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { useRsi } from "../hooks/useRsi" import { useRsi } from "../hooks/useRsi"
import { useState, useCallback } from "react" import { useState, useCallback } from "react"
import { CloseConfirmationDialog } from "./CloseConfirmationDialog"
type Props = { type Props = {
children: React.ReactNode children: React.ReactNode
@@ -29,7 +21,7 @@ type Props = {
} }
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => { export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
const { rtl, translations } = useRsi() const { rtl } = useRsi()
const [showCloseAlert, setShowCloseAlert] = useState(false) const [showCloseAlert, setShowCloseAlert] = useState(false)
// Create a handler that resets scroll positions before closing // Create a handler that resets scroll positions before closing
@@ -76,29 +68,11 @@ export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
</DialogPortal> </DialogPortal>
</Dialog> </Dialog>
<AlertDialog open={showCloseAlert} onOpenChange={setShowCloseAlert}> <CloseConfirmationDialog
<AlertDialogPortal> open={showCloseAlert}
<AlertDialogOverlay className="z-[1400]" /> onOpenChange={setShowCloseAlert}
<AlertDialogContent className="z-[1500]"> onConfirmClose={handleClose}
<AlertDialogHeader> />
<AlertDialogTitle>
{translations.alerts.confirmClose.headerTitle}
</AlertDialogTitle>
<AlertDialogDescription>
{translations.alerts.confirmClose.bodyText}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowCloseAlert(false)}>
{translations.alerts.confirmClose.cancelButtonTitle}
</AlertDialogCancel>
<AlertDialogAction onClick={handleClose}>
{translations.alerts.confirmClose.exitButtonTitle}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
</> </>
) )
} }

View File

@@ -0,0 +1,178 @@
/**
* SaveSessionDialog Component
*
* Dialog for saving the current import session with a name.
*/
import { useState, useEffect } from 'react';
import { Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useImportSession } from '@/contexts/ImportSessionContext';
import { toast } from 'sonner';
interface SaveSessionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Optional suggested name to pre-populate the input */
suggestedName?: string | null;
/** Optional callback after successful save */
onSaved?: () => void;
}
export function SaveSessionDialog({ open, onOpenChange, suggestedName, onSaved }: SaveSessionDialogProps) {
const { saveAsNamed, sessionName, getSuggestedSessionName } = useImportSession();
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
// Initialize name when dialog opens
useEffect(() => {
if (open && !initialized) {
// Priority: existing session name > provided suggested name > auto-generated from data
const initialName = sessionName || suggestedName || getSuggestedSessionName() || '';
setName(initialName);
setInitialized(true);
}
// Reset initialized flag when dialog closes
if (!open) {
setInitialized(false);
}
}, [open, initialized, sessionName, suggestedName, getSuggestedSessionName]);
const handleSave = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
setError('Please enter a name for the session');
return;
}
try {
setSaving(true);
setError(null);
await saveAsNamed(trimmedName);
toast.success('Session saved successfully');
onOpenChange(false);
onSaved?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save session');
} finally {
setSaving(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !saving && name.trim()) {
handleSave();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Save className="h-5 w-5" />
Save Session
</DialogTitle>
<DialogDescription>
Save your current progress with a name so you can restore it later.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="session-name">Session Name</Label>
<Input
id="session-name"
placeholder="e.g., Spring 2025 Products"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
disabled={saving}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* SaveSessionButton Component
*
* A button that opens the save session dialog.
* Shows current save status.
*/
interface SaveSessionButtonProps {
className?: string;
}
export function SaveSessionButton({ className }: SaveSessionButtonProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const { isSaving, lastSaved, sessionName } = useImportSession();
return (
<>
<div className={`flex items-center gap-2 ${className || ''}`}>
{lastSaved && (
<span className="text-xs text-muted-foreground">
{sessionName ? `Saved as "${sessionName}"` : 'Auto-saved'}{' '}
{new Date(lastSaved).toLocaleTimeString()}
</span>
)}
{isSaving && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Saving...
</span>
)}
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(true)}
disabled={isSaving}
>
<Save className="h-4 w-4 mr-1" />
{sessionName ? 'Save As...' : 'Save Session'}
</Button>
</div>
<SaveSessionDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
}

View File

@@ -0,0 +1,240 @@
/**
* SavedSessionsList Component
*
* Displays a list of saved import sessions on the upload step.
* Shows named sessions and the previous unnamed session (if exists).
*/
import { useState, useEffect, useContext } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { Loader2, Trash2, RotateCcw, Clock, FileSpreadsheet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { AuthContext } from '@/contexts/AuthContext';
import { importSessionApi } from '@/services/importSessionApi';
import type { ImportSessionListItem, ImportSession } from '@/types/importSession';
interface SavedSessionsListProps {
onRestore: (session: ImportSession) => void;
}
export function SavedSessionsList({ onRestore }: SavedSessionsListProps) {
const { user } = useContext(AuthContext);
const [sessions, setSessions] = useState<ImportSessionListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [restoringId, setRestoringId] = useState<number | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
// Fetch sessions on mount
useEffect(() => {
if (!user?.id) return;
async function fetchSessions() {
try {
setLoading(true);
setError(null);
const result = await importSessionApi.list(user!.id);
setSessions(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load sessions');
} finally {
setLoading(false);
}
}
fetchSessions();
}, [user?.id]);
// Handle restore
const handleRestore = async (sessionItem: ImportSessionListItem) => {
try {
setRestoringId(sessionItem.id);
// Fetch full session data
const fullSession = await importSessionApi.get(sessionItem.id);
onRestore(fullSession);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to restore session');
} finally {
setRestoringId(null);
}
};
// Handle delete
const handleDelete = async (id: number) => {
try {
setDeletingId(id);
await importSessionApi.delete(id);
setSessions(prev => prev.filter(s => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete session');
} finally {
setDeletingId(null);
setDeleteConfirmId(null);
}
};
// Separate unnamed (previous) session from named sessions
const unnamedSession = sessions.find(s => s.name === null);
const namedSessions = sessions.filter(s => s.name !== null);
// Don't render anything if no sessions
if (!loading && sessions.length === 0) {
return null;
}
return (
<>
<Card className="mt-8">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" />
Saved Sessions
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="text-sm text-destructive py-4">{error}</div>
) : (
<div className="space-y-2">
{/* Previous session (unnamed) */}
{unnamedSession && (
<SessionRow
session={unnamedSession}
isPrevious
isRestoring={restoringId === unnamedSession.id}
isDeleting={deletingId === unnamedSession.id}
onRestore={() => handleRestore(unnamedSession)}
onDelete={() => setDeleteConfirmId(unnamedSession.id)}
/>
)}
{/* Named sessions */}
{namedSessions.map(session => (
<SessionRow
key={session.id}
session={session}
isRestoring={restoringId === session.id}
isDeleting={deletingId === session.id}
onRestore={() => handleRestore(session)}
onDelete={() => setDeleteConfirmId(session.id)}
/>
))}
</div>
)}
</CardContent>
</Card>
{/* Delete confirmation dialog */}
<AlertDialog open={deleteConfirmId !== null} onOpenChange={() => setDeleteConfirmId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Session?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this saved session. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
interface SessionRowProps {
session: ImportSessionListItem;
isPrevious?: boolean;
isRestoring: boolean;
isDeleting: boolean;
onRestore: () => void;
onDelete: () => void;
}
function SessionRow({
session,
isPrevious,
isRestoring,
isDeleting,
onRestore,
onDelete,
}: SessionRowProps) {
const timeAgo = formatDistanceToNow(new Date(session.updated_at), { addSuffix: true });
return (
<div className="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{isPrevious ? (
<span className="text-amber-600">Previous session</span>
) : (
session.name
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-0.5">
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{timeAgo}
</span>
<span>{session.row_count} product{session.row_count !== 1 ? 's' : ''}</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
size="sm"
variant="outline"
onClick={onRestore}
disabled={isRestoring || isDeleting}
>
{isRestoring ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<RotateCcw className="h-4 w-4 mr-1" />
Restore
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={onDelete}
disabled={isRestoring || isDeleting}
className="text-destructive hover:text-destructive/90"
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</>
)}
</Button>
</div>
</div>
);
}

View File

@@ -25,7 +25,11 @@ 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 { AuthContext } from "@/contexts/AuthContext"; import { AuthContext } from "@/contexts/AuthContext";
import { useImportAutosave } from "@/hooks/useImportAutosave";
import { useImportSession } from "@/contexts/ImportSessionContext";
import { SaveSessionButton } from "../../components/SaveSessionDialog";
import type { SubmitOptions } from "../../types"; import type { SubmitOptions } from "../../types";
import type { ImportSessionData } from "@/types/importSession";
interface Props { interface Props {
data: Product[]; data: Product[];
@@ -49,6 +53,9 @@ export const ImageUploadStep = ({
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false); const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false); const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false);
// Import session context for cleanup on submit
const { deleteSession: deleteImportSession } = 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);
@@ -90,6 +97,24 @@ export const ImageUploadStep = ({
handleImageUpload handleImageUpload
}); });
// Autosave hook for session persistence
const { markDirty } = useImportAutosave({
enabled: true,
step: 'imageUpload',
getSessionData: useCallback((): ImportSessionData => {
return {
current_step: 'imageUpload',
data: data as any[], // Product data
product_images: productImages,
};
}, [data, productImages]),
});
// Mark dirty when images change
useEffect(() => {
markDirty();
}, [productImages, markDirty]);
// Set up sensors for drag and drop with enhanced configuration // Set up sensors for drag and drop with enhanced configuration
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -182,22 +207,35 @@ export const ImageUploadStep = ({
}; };
await onSubmit(updatedData, file, submitOptions); await onSubmit(updatedData, file, submitOptions);
// Delete the import session on successful submit
try {
await deleteImportSession();
} catch (err) {
// Non-critical - log but don't fail the submission
console.warn('Failed to delete import session:', err);
}
} catch (error) { } catch (error) {
console.error('Submit error:', error); console.error('Submit error:', error);
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`); toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission]); }, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission, 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">
{/* Header - fixed at top */} {/* Header - fixed at top */}
<div className="px-8 py-6 bg-background shrink-0"> <div className="px-8 py-6 bg-background shrink-0">
<h2 className="text-2xl font-semibold">Add Product Images</h2> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground mt-1"> <div>
Drag images to reorder them or move them between products. <h2 className="text-2xl font-semibold">Add Product Images</h2>
</p> <p className="text-sm text-muted-foreground mt-1">
Drag images to reorder them or move them between products.
</p>
</div>
<SaveSessionButton />
</div>
</div> </div>
{/* Content area - only this part scrolls */} {/* Content area - only this part scrolls */}

View File

@@ -16,6 +16,8 @@ import { Progress } from "@/components/ui/progress"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations" import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { useValidationStore } from "./ValidationStep/store/validationStore" import { useValidationStore } from "./ValidationStep/store/validationStore"
import { useImportSession } from "@/contexts/ImportSessionContext"
import type { ImportSession } from "@/types/importSession"
export enum StepType { export enum StepType {
upload = "upload", upload = "upload",
@@ -127,7 +129,37 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
: undefined : 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) { switch (state.type) {
case StepType.upload: case StepType.upload:
@@ -165,6 +197,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
}); });
} }
}} }}
onRestoreSession={handleRestoreSession}
/> />
) )
case StepType.selectSheet: case StepType.selectSheet:

View File

@@ -1,19 +1,42 @@
import type XLSX from "xlsx" import type XLSX from "xlsx"
import { useCallback, useState } from "react" import { useCallback, useState, useContext } from "react"
import { useRsi } from "../../hooks/useRsi" import { useRsi } from "../../hooks/useRsi"
import { DropZone } from "./components/DropZone" import { DropZone } from "./components/DropZone"
import { StepType } from "../UploadFlow" import { StepType } from "../UploadFlow"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { AuthContext } from "@/contexts/AuthContext"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Bug } from "lucide-react"
import { v4 as uuidv4 } from "uuid"
import { SavedSessionsList } from "../../components/SavedSessionsList"
import type { ImportSession } from "@/types/importSession"
type UploadProps = { type UploadProps = {
onContinue: (data: XLSX.WorkBook, file: File) => Promise<void> onContinue: (data: XLSX.WorkBook, file: File) => Promise<void>
setInitialState?: (state: { type: StepType; data: any[]; isFromScratch?: boolean }) => void setInitialState?: (state: { type: StepType; data: any[]; isFromScratch?: boolean }) => void
onRestoreSession?: (session: ImportSession) => void
} }
export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => { export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: UploadProps) => {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { translations } = useRsi() const { translations } = useRsi()
const { user } = useContext(AuthContext)
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"))
// Debug import state
const [debugDialogOpen, setDebugDialogOpen] = useState(false)
const [debugJsonInput, setDebugJsonInput] = useState("")
const [debugError, setDebugError] = useState<string | null>(null)
const handleOnContinue = useCallback( const handleOnContinue = useCallback(
async (data: XLSX.WorkBook, file: File) => { async (data: XLSX.WorkBook, file: File) => {
@@ -30,10 +53,62 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
} }
}, [setInitialState]) }, [setInitialState])
const handleDebugImport = useCallback(() => {
setDebugError(null)
try {
const parsed = JSON.parse(debugJsonInput)
// Handle both array and object with products property
let products: any[] = Array.isArray(parsed) ? parsed : parsed.products
if (!Array.isArray(products) || products.length === 0) {
setDebugError("JSON must be an array of products or an object with a 'products' array")
return
}
// Add __index to each row if not present (required for validation step)
const dataWithIndex = products.map((row: any) => ({
...row,
__index: row.__index || uuidv4()
}))
if (setInitialState) {
setInitialState({
type: StepType.validateData,
data: dataWithIndex,
isFromScratch: true
})
}
setDebugDialogOpen(false)
setDebugJsonInput("")
} catch (e) {
setDebugError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`)
}
}, [debugJsonInput, setInitialState])
return ( return (
<div className="p-8"> <div className="p-8">
<div className="flex items-baseline justify-between">
<h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2> <h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2>
{hasDebugPermission && (
<>
<div className="flex justify-center">
<Button
onClick={() => setDebugDialogOpen(true)}
variant="outline"
className="min-w-[200px] text-amber-600 border-amber-600 hover:bg-amber-50"
disabled={!setInitialState}
>
<Bug className="mr-2 h-4 w-4" />
Import JSON
</Button>
</div>
</>
)}
</div>
<div className="max-w-xl mx-auto w-full space-y-8"> <div className="max-w-xl mx-auto w-full space-y-8">
<div className="rounded-lg p-6 flex flex-col items-center"> <div className="rounded-lg p-6 flex flex-col items-center">
<DropZone onContinue={handleOnContinue} isLoading={isLoading} /> <DropZone onContinue={handleOnContinue} isLoading={isLoading} />
@@ -45,7 +120,7 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
<Separator className="w-24" /> <Separator className="w-24" />
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center pb-8">
<Button <Button
onClick={handleStartFromScratch} onClick={handleStartFromScratch}
variant="outline" variant="outline"
@@ -55,7 +130,56 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
Start from scratch Start from scratch
</Button> </Button>
</div> </div>
{/* Saved sessions list */}
{onRestoreSession && (
<SavedSessionsList onRestore={onRestoreSession} />
)}
</div> </div>
<Dialog open={debugDialogOpen} onOpenChange={setDebugDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-amber-600">
<Bug className="h-5 w-5" />
Debug: Import JSON Data
</DialogTitle>
<DialogDescription>
Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="debug-json">Product JSON</Label>
<Textarea
id="debug-json"
placeholder='[{"supplier": "...", "company": "...", "name": "...", "product_images": "url1,url2", ...}]'
value={debugJsonInput}
onChange={(e) => {
setDebugJsonInput(e.target.value)
setDebugError(null)
}}
className="min-h-[300px] font-mono text-sm"
/>
{debugError && (
<p className="text-sm text-destructive">{debugError}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDebugDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleDebugImport}
disabled={!debugJsonInput.trim()}
className="bg-amber-600 hover:bg-amber-700"
>
Import & Go to Validation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@@ -6,7 +6,7 @@
* Note: Initialization effects are in index.tsx so they run before this mounts. * Note: Initialization effects are in index.tsx so they run before this mounts.
*/ */
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { useValidationStore } from '../store/validationStore'; import { useValidationStore } from '../store/validationStore';
import { import {
useTotalErrorCount, useTotalErrorCount,
@@ -29,8 +29,10 @@ import { AiDebugDialog } from '../dialogs/AiDebugDialog';
import { SanityCheckDialog } from '../dialogs/SanityCheckDialog'; import { SanityCheckDialog } from '../dialogs/SanityCheckDialog';
import { TemplateForm } from '@/components/templates/TemplateForm'; import { TemplateForm } from '@/components/templates/TemplateForm';
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext'; import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
import { useImportAutosave } from '@/hooks/useImportAutosave';
import type { CleanRowData, RowData } from '../store/types'; import type { CleanRowData, RowData } from '../store/types';
import type { ProductForSanityCheck } from '../hooks/useSanityCheck'; import type { ProductForSanityCheck } from '../hooks/useSanityCheck';
import type { ImportSessionData, SerializedValidationState } from '@/types/importSession';
interface ValidationContainerProps { interface ValidationContainerProps {
onBack?: () => void; onBack?: () => void;
@@ -71,6 +73,46 @@ 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 autosave
const { markDirty } = useImportAutosave({
enabled: true,
step: 'validation',
getSessionData: useCallback((): ImportSessionData => {
const state = useValidationStore.getState();
// Serialize Maps to plain objects for JSON storage
const serializedValidationState: SerializedValidationState = {
errors: Object.fromEntries(
Array.from(state.errors.entries()).map(([k, v]) => [k, v])
),
upcStatus: Object.fromEntries(
Array.from(state.upcStatus.entries()).map(([k, v]) => [k, v])
),
generatedItemNumbers: Object.fromEntries(
Array.from(state.generatedItemNumbers.entries()).map(([k, v]) => [k, v])
),
};
return {
current_step: 'validation',
data: state.rows,
validation_state: serializedValidationState,
};
}, []),
});
// Subscribe to store changes to trigger autosave
useEffect(() => {
// Subscribe to row changes to mark session as dirty
const unsubscribe = useValidationStore.subscribe(
(state) => state.rows,
() => {
markDirty();
}
);
return () => unsubscribe();
}, [markDirty]);
// Get initial products for AI suggestions (read once via ref to avoid re-fetching) // Get initial products for AI suggestions (read once via ref to avoid re-fetching)
const initialProductsRef = useRef<RowData[] | null>(null); const initialProductsRef = useRef<RowData[] | null>(null);
if (initialProductsRef.current === null) { if (initialProductsRef.current === null) {

View File

@@ -24,6 +24,7 @@ import {
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '../../../CreateProductCategoryDialog'; import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '../../../CreateProductCategoryDialog';
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'; import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog';
import { useTemplateManagement } from '../hooks/useTemplateManagement'; import { useTemplateManagement } from '../hooks/useTemplateManagement';
import { SaveSessionButton } from '../../../components/SaveSessionDialog';
interface ValidationToolbarProps { interface ValidationToolbarProps {
rowCount: number; rowCount: number;
@@ -165,6 +166,9 @@ export const ValidationToolbar = ({
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
{/* Save session */}
<SaveSessionButton />
{/* Add row */} {/* Add row */}
<Button variant="outline" size="sm" onClick={() => handleAddRow()}> <Button variant="outline" size="sm" onClick={() => handleAddRow()}>
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />

View File

@@ -0,0 +1,322 @@
/**
* Import Session Context
*
* Manages import session state across the product import flow,
* including autosave and named session management.
*/
import {
createContext,
useState,
useCallback,
useContext,
useRef,
useEffect,
ReactNode,
} from 'react';
import { AuthContext } from './AuthContext';
import {
autosaveSession,
createSession,
updateSession,
deleteSession,
} from '@/services/importSessionApi';
import type {
ImportSession,
ImportSessionData,
ImportSessionContextValue,
} from '@/types/importSession';
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 seconds
const defaultContext: ImportSessionContextValue = {
sessionId: null,
sessionName: null,
isDirty: false,
lastSaved: null,
isSaving: false,
currentStep: 'validation',
setCurrentStep: () => {},
markDirty: () => {},
saveAsNamed: async () => { throw new Error('Not initialized'); },
save: async () => {},
forceSave: async () => {},
loadSession: () => {},
clearSession: () => {},
deleteSession: async () => {},
setDataGetter: () => {},
getSuggestedSessionName: () => null,
};
export const ImportSessionContext = createContext<ImportSessionContextValue>(defaultContext);
interface ImportSessionProviderProps {
children: ReactNode;
}
export function ImportSessionProvider({ children }: ImportSessionProviderProps) {
const { user } = useContext(AuthContext);
// Session state
const [sessionId, setSessionId] = useState<number | null>(null);
const [sessionName, setSessionName] = useState<string | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [currentStep, setCurrentStep] = useState<'validation' | 'imageUpload'>('validation');
// Ref to hold the data getter function (set by ValidationStep/ImageUploadStep)
const dataGetterRef = useRef<(() => ImportSessionData) | null>(null);
// Autosave timer ref
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clear autosave timer on unmount
useEffect(() => {
return () => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
}
};
}, []);
/**
* Set the data getter function (called by child components)
*/
const setDataGetter = useCallback((getter: () => ImportSessionData) => {
dataGetterRef.current = getter;
}, []);
/**
* Mark the session as having unsaved changes
*/
const markDirty = useCallback(() => {
setIsDirty(true);
// Schedule autosave
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
}
autosaveTimerRef.current = setTimeout(() => {
// Trigger autosave if we have a data getter and user
if (dataGetterRef.current && user?.id) {
const data = dataGetterRef.current();
performAutosave(data);
}
}, AUTOSAVE_DEBOUNCE_MS);
}, [user?.id]);
/**
* 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)
*/
const save = useCallback(async () => {
if (!dataGetterRef.current || !user?.id) return;
const data = dataGetterRef.current();
await performAutosave(data);
}, [user?.id, performAutosave]);
/**
* Force immediate save, bypassing debounce (used before closing)
*/
const forceSave = useCallback(async () => {
// Clear any pending autosave timer
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
// Only save if dirty and we have data
if (!isDirty || !dataGetterRef.current || !user?.id) return;
const data = dataGetterRef.current();
await performAutosave(data);
}, [isDirty, user?.id, performAutosave]);
/**
* Get a suggested session name based on data (Company - Line format)
*/
const getSuggestedSessionName = useCallback((): string | null => {
if (!dataGetterRef.current) return null;
try {
const data = dataGetterRef.current();
if (!data.data || data.data.length === 0) return null;
// Find the first row with both company and line
for (const row of data.data) {
const company = row.company;
const line = row.line;
if (company && line) {
// Convert values to display strings
const companyStr = typeof company === 'string' ? company : String(company);
const lineStr = typeof line === 'string' ? line : String(line);
if (companyStr.trim() && lineStr.trim()) {
return `${companyStr} - ${lineStr}`;
}
}
}
// If no row has both, try company alone
for (const row of data.data) {
const company = row.company;
if (company) {
const companyStr = typeof company === 'string' ? company : String(company);
if (companyStr.trim()) {
return companyStr;
}
}
}
return null;
} catch {
return null;
}
}, []);
/**
* Save current state as a new named session
*/
const saveAsNamed = useCallback(async (name: string): Promise<ImportSession> => {
if (!dataGetterRef.current || !user?.id) {
throw new Error('Cannot save: no data or user');
}
setIsSaving(true);
try {
const data = dataGetterRef.current();
const result = await createSession({
user_id: user.id,
name,
...data,
});
// Update context to track this named session
setSessionId(result.id);
setSessionName(name);
setLastSaved(new Date());
setIsDirty(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;
} finally {
setIsSaving(false);
}
}, [user?.id]);
/**
* Load a session (called when restoring from saved sessions list)
*/
const loadSession = useCallback((session: ImportSession) => {
setSessionId(session.id);
setSessionName(session.name);
setCurrentStep(session.current_step);
setLastSaved(new Date(session.updated_at));
setIsDirty(false);
}, []);
/**
* Clear current session state (for starting fresh)
*/
const clearSession = useCallback(() => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
}
setSessionId(null);
setSessionName(null);
setIsDirty(false);
setLastSaved(null);
setCurrentStep('validation');
dataGetterRef.current = null;
}, []);
/**
* Delete the current session (called on successful submit)
*/
const handleDeleteSession = useCallback(async () => {
if (!sessionId) return;
try {
await deleteSession(sessionId);
clearSession();
} catch (error) {
console.error('Failed to delete session:', error);
// Clear local state anyway
clearSession();
}
}, [sessionId, clearSession]);
return (
<ImportSessionContext.Provider
value={{
sessionId,
sessionName,
isDirty,
lastSaved,
isSaving,
currentStep,
setCurrentStep,
markDirty,
saveAsNamed,
save,
forceSave,
loadSession,
clearSession,
deleteSession: handleDeleteSession,
setDataGetter,
getSuggestedSessionName,
}}
>
{children}
</ImportSessionContext.Provider>
);
}
/**
* Hook to use the import session context
*/
export function useImportSession() {
const context = useContext(ImportSessionContext);
if (!context) {
throw new Error('useImportSession must be used within an ImportSessionProvider');
}
return context;
}

View File

@@ -0,0 +1,94 @@
/**
* useImportAutosave Hook
*
* Connects a component to the import session autosave system.
* Registers a data getter and tracks changes to trigger autosaves.
*/
import { useEffect, useCallback, useRef } from 'react';
import { useImportSession } from '@/contexts/ImportSessionContext';
import type { ImportSessionData } from '@/types/importSession';
interface UseImportAutosaveOptions {
/** Whether autosave is enabled for this component */
enabled: boolean;
/** Function that returns the current session data to save */
getSessionData: () => ImportSessionData;
/** Current step identifier */
step: 'validation' | 'imageUpload';
}
/**
* Hook to integrate a component with the import session autosave system.
*
* @example
* ```tsx
* const { markDirty } = useImportAutosave({
* enabled: true,
* step: 'validation',
* getSessionData: () => ({
* current_step: 'validation',
* data: rows,
* validation_state: { errors, upcStatus, generatedItemNumbers },
* }),
* });
*
* // Call markDirty whenever data changes
* const handleCellChange = (value) => {
* updateCell(value);
* markDirty();
* };
* ```
*/
export function useImportAutosave({
enabled,
getSessionData,
step,
}: UseImportAutosaveOptions) {
const {
setDataGetter,
setCurrentStep,
markDirty: contextMarkDirty,
isSaving,
lastSaved,
sessionId,
sessionName,
} = useImportSession();
// Keep getSessionData ref updated to avoid stale closures
const getSessionDataRef = useRef(getSessionData);
useEffect(() => {
getSessionDataRef.current = getSessionData;
}, [getSessionData]);
// Register the data getter with the context
useEffect(() => {
if (enabled) {
setDataGetter(() => getSessionDataRef.current());
setCurrentStep(step);
}
}, [enabled, setDataGetter, setCurrentStep, step]);
/**
* Mark the session as dirty (has unsaved changes).
* This triggers the debounced autosave in the context.
*/
const markDirty = useCallback(() => {
if (enabled) {
contextMarkDirty();
}
}, [enabled, contextMarkDirty]);
return {
/** Call this when data changes to trigger autosave */
markDirty,
/** Whether a save operation is currently in progress */
isSaving,
/** Timestamp of last successful save */
lastSaved,
/** Current session ID (null if new/unsaved) */
sessionId,
/** Current session name (null if unnamed) */
sessionName,
};
}

View File

@@ -0,0 +1,139 @@
/**
* Import Session API Service
*
* Handles all API calls for import session persistence.
*/
import type {
ImportSession,
ImportSessionListItem,
ImportSessionCreateRequest,
ImportSessionUpdateRequest,
ImportSessionAutosaveRequest,
} from '@/types/importSession';
const BASE_URL = '/api/import-sessions';
/**
* Helper to handle API responses
*/
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorBody = await response.text();
let errorMessage: string;
try {
const parsed = JSON.parse(errorBody);
errorMessage = parsed.error || parsed.message || `HTTP ${response.status}`;
} catch {
errorMessage = errorBody || `HTTP ${response.status}`;
}
throw new Error(errorMessage);
}
return response.json();
}
/**
* List all sessions for a user (named + unnamed)
*/
export async function listSessions(userId: number): Promise<ImportSessionListItem[]> {
const response = await fetch(`${BASE_URL}?user_id=${userId}`);
return handleResponse<ImportSessionListItem[]>(response);
}
/**
* Get a specific session by ID (includes full data)
*/
export async function getSession(id: number): Promise<ImportSession> {
const response = await fetch(`${BASE_URL}/${id}`);
return handleResponse<ImportSession>(response);
}
/**
* Create a new named session
*/
export async function createSession(data: ImportSessionCreateRequest): Promise<ImportSession> {
const response = await fetch(BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return handleResponse<ImportSession>(response);
}
/**
* Update an existing session by ID
*/
export async function updateSession(id: number, data: ImportSessionUpdateRequest): Promise<ImportSession> {
const response = await fetch(`${BASE_URL}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return handleResponse<ImportSession>(response);
}
/**
* Autosave - upsert the unnamed session for a user
*/
export async function autosaveSession(data: ImportSessionAutosaveRequest): Promise<ImportSession> {
const response = await fetch(`${BASE_URL}/autosave`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return handleResponse<ImportSession>(response);
}
/**
* Delete a session by ID
*/
export async function deleteSession(id: number): Promise<void> {
const response = await fetch(`${BASE_URL}/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorBody = await response.text();
let errorMessage: string;
try {
const parsed = JSON.parse(errorBody);
errorMessage = parsed.error || parsed.message || `HTTP ${response.status}`;
} catch {
errorMessage = errorBody || `HTTP ${response.status}`;
}
throw new Error(errorMessage);
}
}
/**
* Delete the unnamed/autosave session for a user
*/
export async function deleteAutosaveSession(userId: number): Promise<void> {
const response = await fetch(`${BASE_URL}/autosave/${userId}`, {
method: 'DELETE',
});
// 404 is ok - means no autosave existed
if (!response.ok && response.status !== 404) {
const errorBody = await response.text();
let errorMessage: string;
try {
const parsed = JSON.parse(errorBody);
errorMessage = parsed.error || parsed.message || `HTTP ${response.status}`;
} catch {
errorMessage = errorBody || `HTTP ${response.status}`;
}
throw new Error(errorMessage);
}
}
/**
* Convenience export for all API methods
*/
export const importSessionApi = {
list: listSessions,
get: getSession,
create: createSession,
update: updateSession,
autosave: autosaveSession,
delete: deleteSession,
deleteAutosave: deleteAutosaveSession,
};

View File

@@ -0,0 +1,153 @@
/**
* Import Session Types
*
* Types for managing product import session persistence,
* including autosave and named session management.
*/
import type { RowData, ValidationError, UpcValidationStatus } from '@/components/product-import/steps/ValidationStep/store/types';
import type { ProductImageSortable } from '@/components/product-import/steps/ImageUploadStep/types';
import type { GlobalSelections } from '@/components/product-import/steps/MatchColumnsStep/types';
// =============================================================================
// Session Data Types
// =============================================================================
/**
* Serialized validation state for database storage.
* Maps are converted to plain objects for JSONB storage.
*/
export interface SerializedValidationState {
/** Errors by row index, then by field key */
errors: Record<number, Record<string, ValidationError[]>>;
/** UPC validation status by row index */
upcStatus: Record<number, UpcValidationStatus>;
/** Generated item numbers by row index */
generatedItemNumbers: Record<number, string>;
}
/**
* Session data payload for API requests (create/update)
*/
export interface ImportSessionData {
current_step: 'validation' | 'imageUpload';
data: RowData[];
product_images?: ProductImageSortable[];
global_selections?: GlobalSelections;
validation_state?: SerializedValidationState;
}
/**
* Full session record from the database
*/
export interface ImportSession {
id: number;
user_id: number;
name: string | null;
current_step: 'validation' | 'imageUpload';
data: RowData[];
product_images: ProductImageSortable[] | null;
global_selections: GlobalSelections | null;
validation_state: SerializedValidationState | null;
created_at: string;
updated_at: string;
}
/**
* Session list item (lighter version without full data)
*/
export interface ImportSessionListItem {
id: number;
user_id: number;
name: string | null;
current_step: 'validation' | 'imageUpload';
row_count: number;
global_selections: GlobalSelections | null;
created_at: string;
updated_at: string;
}
// =============================================================================
// Context Types
// =============================================================================
/**
* Import session context state and actions
*/
export interface ImportSessionContextValue {
/** Current session ID (null = new/unnamed session) */
sessionId: number | null;
/** Session name (null = unnamed autosave) */
sessionName: string | null;
/** Whether there are unsaved changes */
isDirty: boolean;
/** Timestamp of last successful save */
lastSaved: Date | null;
/** Whether a save operation is in progress */
isSaving: boolean;
/** Current step for session tracking */
currentStep: 'validation' | 'imageUpload';
/** Set the current step */
setCurrentStep: (step: 'validation' | 'imageUpload') => void;
/** Mark session as dirty (has unsaved changes) */
markDirty: () => void;
/** Save current state as a named session */
saveAsNamed: (name: string) => Promise<ImportSession>;
/** Update existing named session or create autosave */
save: () => Promise<void>;
/** Force immediate save (ignoring debounce), used before closing */
forceSave: () => Promise<void>;
/** Load a session by ID */
loadSession: (session: ImportSession) => void;
/** Clear current session state */
clearSession: () => void;
/** Delete session from server (called on submit) */
deleteSession: () => Promise<void>;
/** Set the data getter function for autosave */
setDataGetter: (getter: () => ImportSessionData) => void;
/** Get a suggested session name based on data (Company - Line) */
getSuggestedSessionName: () => string | null;
}
// =============================================================================
// Hook Types
// =============================================================================
export interface UseImportAutosaveOptions {
/** Whether autosave is enabled */
enabled: boolean;
/** Debounce delay in milliseconds (default: 10000) */
debounceMs?: number;
}
// =============================================================================
// API Response Types
// =============================================================================
export interface ImportSessionCreateRequest {
user_id: number;
name: string;
current_step: 'validation' | 'imageUpload';
data: RowData[];
product_images?: ProductImageSortable[];
global_selections?: GlobalSelections;
validation_state?: SerializedValidationState;
}
export interface ImportSessionUpdateRequest {
current_step: 'validation' | 'imageUpload';
data: RowData[];
product_images?: ProductImageSortable[];
global_selections?: GlobalSelections;
validation_state?: SerializedValidationState;
}
export interface ImportSessionAutosaveRequest {
user_id: number;
current_step: 'validation' | 'imageUpload';
data: RowData[];
product_images?: ProductImageSortable[];
global_selections?: GlobalSelections;
validation_state?: SerializedValidationState;
}

File diff suppressed because one or more lines are too long