Add import session save/restore
This commit is contained in:
29
inventory-server/migrations/001_create_import_sessions.sql
Normal file
29
inventory-server/migrations/001_create_import_sessions.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- Migration: Create import_sessions table
|
||||||
|
-- Run this against your PostgreSQL database
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS import_sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name VARCHAR(255), -- NULL for unnamed/autosave sessions
|
||||||
|
current_step VARCHAR(50) NOT NULL, -- 'validation' | 'imageUpload'
|
||||||
|
data JSONB NOT NULL, -- Product rows
|
||||||
|
product_images JSONB, -- Image assignments
|
||||||
|
global_selections JSONB, -- Supplier, company, line, subline
|
||||||
|
validation_state JSONB, -- Errors, UPC status, generated item numbers
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ensure only one unnamed session per user (autosave slot)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_unnamed_session_per_user
|
||||||
|
ON import_sessions (user_id)
|
||||||
|
WHERE name IS NULL;
|
||||||
|
|
||||||
|
-- Index for fast user lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_sessions_user_id
|
||||||
|
ON import_sessions (user_id);
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON TABLE import_sessions IS 'Stores in-progress product import sessions for users';
|
||||||
|
COMMENT ON COLUMN import_sessions.name IS 'Session name - NULL indicates the single unnamed/autosave session per user';
|
||||||
|
COMMENT ON COLUMN import_sessions.current_step IS 'Which step the user was on: validation or imageUpload';
|
||||||
325
inventory-server/src/routes/import-sessions.js
Normal file
325
inventory-server/src/routes/import-sessions.js
Normal 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;
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Providers } from "./components/Providers"
|
|||||||
import type { RsiProps } from "./types"
|
import type { RsiProps } from "./types"
|
||||||
import { ModalWrapper } from "./components/ModalWrapper"
|
import { ModalWrapper } from "./components/ModalWrapper"
|
||||||
import { translations } from "./translationsRSIProps"
|
import { translations } from "./translationsRSIProps"
|
||||||
|
import { ImportSessionProvider } from "@/contexts/ImportSessionContext"
|
||||||
|
|
||||||
// Simple empty theme placeholder
|
// Simple empty theme placeholder
|
||||||
export const defaultTheme = {}
|
export const defaultTheme = {}
|
||||||
@@ -29,10 +30,12 @@ export const ReactSpreadsheetImport = <T extends string>(propsWithoutDefaults: R
|
|||||||
props.translations !== translations ? merge(translations, props.translations) : translations
|
props.translations !== translations ? merge(translations, props.translations) : translations
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers rsiValues={{ ...props, translations: mergedTranslations }}>
|
<ImportSessionProvider>
|
||||||
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
|
<Providers rsiValues={{ ...props, translations: mergedTranslations }}>
|
||||||
<Steps />
|
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
|
||||||
</ModalWrapper>
|
<Steps />
|
||||||
</Providers>
|
</ModalWrapper>
|
||||||
|
</Providers>
|
||||||
|
</ImportSessionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,9 +21,9 @@ 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
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
// Reset all scroll positions in the dialog
|
// Reset all scroll positions in the dialog
|
||||||
@@ -43,11 +35,11 @@ export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
|
|||||||
container.scrollLeft = 0;
|
container.scrollLeft = 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call the original onClose handler
|
// Call the original onClose handler
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={() => setShowCloseAlert(true)} modal>
|
<Dialog open={isOpen} onOpenChange={() => setShowCloseAlert(true)} modal>
|
||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* SaveSessionDialog Component
|
||||||
|
*
|
||||||
|
* Dialog for saving the current import session with a name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Loader2, Save } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useImportSession } from '@/contexts/ImportSessionContext';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface SaveSessionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Optional suggested name to pre-populate the input */
|
||||||
|
suggestedName?: string | null;
|
||||||
|
/** Optional callback after successful save */
|
||||||
|
onSaved?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveSessionDialog({ open, onOpenChange, suggestedName, onSaved }: SaveSessionDialogProps) {
|
||||||
|
const { saveAsNamed, sessionName, getSuggestedSessionName } = useImportSession();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Initialize name when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !initialized) {
|
||||||
|
// Priority: existing session name > provided suggested name > auto-generated from data
|
||||||
|
const initialName = sessionName || suggestedName || getSuggestedSessionName() || '';
|
||||||
|
setName(initialName);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
// Reset initialized flag when dialog closes
|
||||||
|
if (!open) {
|
||||||
|
setInitialized(false);
|
||||||
|
}
|
||||||
|
}, [open, initialized, sessionName, suggestedName, getSuggestedSessionName]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
setError('Please enter a name for the session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
await saveAsNamed(trimmedName);
|
||||||
|
toast.success('Session saved successfully');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSaved?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save session');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !saving && name.trim()) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Save className="h-5 w-5" />
|
||||||
|
Save Session
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Save your current progress with a name so you can restore it later.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="session-name">Session Name</Label>
|
||||||
|
<Input
|
||||||
|
id="session-name"
|
||||||
|
placeholder="e.g., Spring 2025 Products"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={saving}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || !name.trim()}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SaveSessionButton Component
|
||||||
|
*
|
||||||
|
* A button that opens the save session dialog.
|
||||||
|
* Shows current save status.
|
||||||
|
*/
|
||||||
|
interface SaveSessionButtonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveSessionButton({ className }: SaveSessionButtonProps) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const { isSaving, lastSaved, sessionName } = useImportSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`flex items-center gap-2 ${className || ''}`}>
|
||||||
|
{lastSaved && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{sessionName ? `Saved as "${sessionName}"` : 'Auto-saved'}{' '}
|
||||||
|
{new Date(lastSaved).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isSaving && (
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4 mr-1" />
|
||||||
|
{sessionName ? 'Save As...' : 'Save Session'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SaveSessionDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* SavedSessionsList Component
|
||||||
|
*
|
||||||
|
* Displays a list of saved import sessions on the upload step.
|
||||||
|
* Shows named sessions and the previous unnamed session (if exists).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useContext } from 'react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Loader2, Trash2, RotateCcw, Clock, FileSpreadsheet } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { AuthContext } from '@/contexts/AuthContext';
|
||||||
|
import { importSessionApi } from '@/services/importSessionApi';
|
||||||
|
import type { ImportSessionListItem, ImportSession } from '@/types/importSession';
|
||||||
|
|
||||||
|
interface SavedSessionsListProps {
|
||||||
|
onRestore: (session: ImportSession) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SavedSessionsList({ onRestore }: SavedSessionsListProps) {
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
const [sessions, setSessions] = useState<ImportSessionListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
|
const [restoringId, setRestoringId] = useState<number | null>(null);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Fetch sessions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
async function fetchSessions() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await importSessionApi.list(user!.id);
|
||||||
|
setSessions(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load sessions');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSessions();
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
// Handle restore
|
||||||
|
const handleRestore = async (sessionItem: ImportSessionListItem) => {
|
||||||
|
try {
|
||||||
|
setRestoringId(sessionItem.id);
|
||||||
|
// Fetch full session data
|
||||||
|
const fullSession = await importSessionApi.get(sessionItem.id);
|
||||||
|
onRestore(fullSession);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to restore session');
|
||||||
|
} finally {
|
||||||
|
setRestoringId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle delete
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
setDeletingId(id);
|
||||||
|
await importSessionApi.delete(id);
|
||||||
|
setSessions(prev => prev.filter(s => s.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete session');
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separate unnamed (previous) session from named sessions
|
||||||
|
const unnamedSession = sessions.find(s => s.name === null);
|
||||||
|
const namedSessions = sessions.filter(s => s.name !== null);
|
||||||
|
|
||||||
|
// Don't render anything if no sessions
|
||||||
|
if (!loading && sessions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<FileSpreadsheet className="h-5 w-5" />
|
||||||
|
Saved Sessions
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-sm text-destructive py-4">{error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Previous session (unnamed) */}
|
||||||
|
{unnamedSession && (
|
||||||
|
<SessionRow
|
||||||
|
session={unnamedSession}
|
||||||
|
isPrevious
|
||||||
|
isRestoring={restoringId === unnamedSession.id}
|
||||||
|
isDeleting={deletingId === unnamedSession.id}
|
||||||
|
onRestore={() => handleRestore(unnamedSession)}
|
||||||
|
onDelete={() => setDeleteConfirmId(unnamedSession.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Named sessions */}
|
||||||
|
{namedSessions.map(session => (
|
||||||
|
<SessionRow
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
isRestoring={restoringId === session.id}
|
||||||
|
isDeleting={deletingId === session.id}
|
||||||
|
onRestore={() => handleRestore(session)}
|
||||||
|
onDelete={() => setDeleteConfirmId(session.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<AlertDialog open={deleteConfirmId !== null} onOpenChange={() => setDeleteConfirmId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Session?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete this saved session. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionRowProps {
|
||||||
|
session: ImportSessionListItem;
|
||||||
|
isPrevious?: boolean;
|
||||||
|
isRestoring: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
|
onRestore: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRow({
|
||||||
|
session,
|
||||||
|
isPrevious,
|
||||||
|
isRestoring,
|
||||||
|
isDeleting,
|
||||||
|
onRestore,
|
||||||
|
onDelete,
|
||||||
|
}: SessionRowProps) {
|
||||||
|
const timeAgo = formatDistanceToNow(new Date(session.updated_at), { addSuffix: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{isPrevious ? (
|
||||||
|
<span className="text-amber-600">Previous session</span>
|
||||||
|
) : (
|
||||||
|
session.name
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-0.5">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
{timeAgo}
|
||||||
|
</span>
|
||||||
|
<span>{session.row_count} product{session.row_count !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRestore}
|
||||||
|
disabled={isRestoring || isDeleting}
|
||||||
|
>
|
||||||
|
{isRestoring ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-1" />
|
||||||
|
Restore
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isRestoring || isDeleting}
|
||||||
|
className="text-destructive hover:text-destructive/90"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
Delete
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
@@ -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[];
|
||||||
@@ -48,7 +52,10 @@ export const ImageUploadStep = ({
|
|||||||
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
|
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
|
||||||
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
|
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
|
||||||
const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false);
|
const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
@@ -89,7 +96,25 @@ export const ImageUploadStep = ({
|
|||||||
data,
|
data,
|
||||||
handleImageUpload
|
handleImageUpload
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Autosave hook for session persistence
|
||||||
|
const { markDirty } = useImportAutosave({
|
||||||
|
enabled: true,
|
||||||
|
step: 'imageUpload',
|
||||||
|
getSessionData: useCallback((): ImportSessionData => {
|
||||||
|
return {
|
||||||
|
current_step: 'imageUpload',
|
||||||
|
data: data as any[], // Product data
|
||||||
|
product_images: productImages,
|
||||||
|
};
|
||||||
|
}, [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 */}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
import type XLSX from "xlsx"
|
import type XLSX from "xlsx"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState, useContext } from "react"
|
||||||
import { useRsi } from "../../hooks/useRsi"
|
import { useRsi } from "../../hooks/useRsi"
|
||||||
import { DropZone } from "./components/DropZone"
|
import { DropZone } from "./components/DropZone"
|
||||||
import { StepType } from "../UploadFlow"
|
import { StepType } from "../UploadFlow"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Bug } from "lucide-react"
|
||||||
|
import { v4 as uuidv4 } from "uuid"
|
||||||
|
import { SavedSessionsList } from "../../components/SavedSessionsList"
|
||||||
|
import type { ImportSession } from "@/types/importSession"
|
||||||
|
|
||||||
type UploadProps = {
|
type UploadProps = {
|
||||||
onContinue: (data: XLSX.WorkBook, file: File) => Promise<void>
|
onContinue: (data: XLSX.WorkBook, file: File) => Promise<void>
|
||||||
setInitialState?: (state: { type: StepType; data: any[]; isFromScratch?: boolean }) => void
|
setInitialState?: (state: { type: StepType; data: any[]; isFromScratch?: boolean }) => void
|
||||||
|
onRestoreSession?: (session: ImportSession) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: UploadProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { translations } = useRsi()
|
const { translations } = useRsi()
|
||||||
|
const { user } = useContext(AuthContext)
|
||||||
|
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"))
|
||||||
|
|
||||||
|
// Debug import state
|
||||||
|
const [debugDialogOpen, setDebugDialogOpen] = useState(false)
|
||||||
|
const [debugJsonInput, setDebugJsonInput] = useState("")
|
||||||
|
const [debugError, setDebugError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleOnContinue = useCallback(
|
const handleOnContinue = useCallback(
|
||||||
async (data: XLSX.WorkBook, file: File) => {
|
async (data: XLSX.WorkBook, file: File) => {
|
||||||
@@ -29,11 +52,63 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
|||||||
setInitialState({ type: StepType.validateData, data: [{}], isFromScratch: true })
|
setInitialState({ type: StepType.validateData, data: [{}], isFromScratch: true })
|
||||||
}
|
}
|
||||||
}, [setInitialState])
|
}, [setInitialState])
|
||||||
|
|
||||||
|
const handleDebugImport = useCallback(() => {
|
||||||
|
setDebugError(null)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(debugJsonInput)
|
||||||
|
|
||||||
|
// Handle both array and object with products property
|
||||||
|
let products: any[] = Array.isArray(parsed) ? parsed : parsed.products
|
||||||
|
|
||||||
|
if (!Array.isArray(products) || products.length === 0) {
|
||||||
|
setDebugError("JSON must be an array of products or an object with a 'products' array")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add __index to each row if not present (required for validation step)
|
||||||
|
const dataWithIndex = products.map((row: any) => ({
|
||||||
|
...row,
|
||||||
|
__index: row.__index || uuidv4()
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (setInitialState) {
|
||||||
|
setInitialState({
|
||||||
|
type: StepType.validateData,
|
||||||
|
data: dataWithIndex,
|
||||||
|
isFromScratch: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setDebugDialogOpen(false)
|
||||||
|
setDebugJsonInput("")
|
||||||
|
} catch (e) {
|
||||||
|
setDebugError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`)
|
||||||
|
}
|
||||||
|
}, [debugJsonInput, setInitialState])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2>
|
<h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2>
|
||||||
|
{hasDebugPermission && (
|
||||||
|
<>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => setDebugDialogOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
className="min-w-[200px] text-amber-600 border-amber-600 hover:bg-amber-50"
|
||||||
|
disabled={!setInitialState}
|
||||||
|
>
|
||||||
|
<Bug className="mr-2 h-4 w-4" />
|
||||||
|
Import JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="max-w-xl mx-auto w-full space-y-8">
|
<div className="max-w-xl mx-auto w-full space-y-8">
|
||||||
<div className="rounded-lg p-6 flex flex-col items-center">
|
<div className="rounded-lg p-6 flex flex-col items-center">
|
||||||
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
|
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
|
||||||
@@ -45,8 +120,8 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
|||||||
<Separator className="w-24" />
|
<Separator className="w-24" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center pb-8">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStartFromScratch}
|
onClick={handleStartFromScratch}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="min-w-[200px]"
|
className="min-w-[200px]"
|
||||||
@@ -55,7 +130,56 @@ export const UploadStep = ({ onContinue, setInitialState }: UploadProps) => {
|
|||||||
Start from scratch
|
Start from scratch
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Saved sessions list */}
|
||||||
|
{onRestoreSession && (
|
||||||
|
<SavedSessionsList onRestore={onRestoreSession} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={debugDialogOpen} onOpenChange={setDebugDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-amber-600">
|
||||||
|
<Bug className="h-5 w-5" />
|
||||||
|
Debug: Import JSON Data
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="debug-json">Product JSON</Label>
|
||||||
|
<Textarea
|
||||||
|
id="debug-json"
|
||||||
|
placeholder='[{"supplier": "...", "company": "...", "name": "...", "product_images": "url1,url2", ...}]'
|
||||||
|
value={debugJsonInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDebugJsonInput(e.target.value)
|
||||||
|
setDebugError(null)
|
||||||
|
}}
|
||||||
|
className="min-h-[300px] font-mono text-sm"
|
||||||
|
/>
|
||||||
|
{debugError && (
|
||||||
|
<p className="text-sm text-destructive">{debugError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDebugDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDebugImport}
|
||||||
|
disabled={!debugJsonInput.trim()}
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
Import & Go to Validation
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Note: Initialization effects are in index.tsx so they run before this mounts.
|
* Note: Initialization effects are in index.tsx so they run before this mounts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
|
||||||
import { useValidationStore } from '../store/validationStore';
|
import { useValidationStore } from '../store/validationStore';
|
||||||
import {
|
import {
|
||||||
useTotalErrorCount,
|
useTotalErrorCount,
|
||||||
@@ -29,8 +29,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) {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
322
inventory/src/contexts/ImportSessionContext.tsx
Normal file
322
inventory/src/contexts/ImportSessionContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
94
inventory/src/hooks/useImportAutosave.ts
Normal file
94
inventory/src/hooks/useImportAutosave.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
139
inventory/src/services/importSessionApi.ts
Normal file
139
inventory/src/services/importSessionApi.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Import Session API Service
|
||||||
|
*
|
||||||
|
* Handles all API calls for import session persistence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ImportSession,
|
||||||
|
ImportSessionListItem,
|
||||||
|
ImportSessionCreateRequest,
|
||||||
|
ImportSessionUpdateRequest,
|
||||||
|
ImportSessionAutosaveRequest,
|
||||||
|
} from '@/types/importSession';
|
||||||
|
|
||||||
|
const BASE_URL = '/api/import-sessions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to handle API responses
|
||||||
|
*/
|
||||||
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text();
|
||||||
|
let errorMessage: string;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(errorBody);
|
||||||
|
errorMessage = parsed.error || parsed.message || `HTTP ${response.status}`;
|
||||||
|
} catch {
|
||||||
|
errorMessage = errorBody || `HTTP ${response.status}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all sessions for a user (named + unnamed)
|
||||||
|
*/
|
||||||
|
export async function listSessions(userId: number): Promise<ImportSessionListItem[]> {
|
||||||
|
const response = await fetch(`${BASE_URL}?user_id=${userId}`);
|
||||||
|
return handleResponse<ImportSessionListItem[]>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific session by ID (includes full data)
|
||||||
|
*/
|
||||||
|
export async function getSession(id: number): Promise<ImportSession> {
|
||||||
|
const response = await fetch(`${BASE_URL}/${id}`);
|
||||||
|
return handleResponse<ImportSession>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new named session
|
||||||
|
*/
|
||||||
|
export async function createSession(data: ImportSessionCreateRequest): Promise<ImportSession> {
|
||||||
|
const response = await fetch(BASE_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<ImportSession>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing session by ID
|
||||||
|
*/
|
||||||
|
export async function updateSession(id: number, data: ImportSessionUpdateRequest): Promise<ImportSession> {
|
||||||
|
const response = await fetch(`${BASE_URL}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<ImportSession>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autosave - upsert the unnamed session for a user
|
||||||
|
*/
|
||||||
|
export async function autosaveSession(data: ImportSessionAutosaveRequest): Promise<ImportSession> {
|
||||||
|
const response = await fetch(`${BASE_URL}/autosave`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<ImportSession>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a session by ID
|
||||||
|
*/
|
||||||
|
export async function deleteSession(id: number): Promise<void> {
|
||||||
|
const response = await fetch(`${BASE_URL}/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text();
|
||||||
|
let errorMessage: string;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(errorBody);
|
||||||
|
errorMessage = parsed.error || parsed.message || `HTTP ${response.status}`;
|
||||||
|
} catch {
|
||||||
|
errorMessage = errorBody || `HTTP ${response.status}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the unnamed/autosave session for a user
|
||||||
|
*/
|
||||||
|
export async function deleteAutosaveSession(userId: number): Promise<void> {
|
||||||
|
const response = await fetch(`${BASE_URL}/autosave/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
// 404 is ok - means no autosave existed
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
const errorBody = await response.text();
|
||||||
|
let errorMessage: string;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(errorBody);
|
||||||
|
errorMessage = parsed.error || parsed.message || `HTTP ${response.status}`;
|
||||||
|
} catch {
|
||||||
|
errorMessage = errorBody || `HTTP ${response.status}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience export for all API methods
|
||||||
|
*/
|
||||||
|
export const importSessionApi = {
|
||||||
|
list: listSessions,
|
||||||
|
get: getSession,
|
||||||
|
create: createSession,
|
||||||
|
update: updateSession,
|
||||||
|
autosave: autosaveSession,
|
||||||
|
delete: deleteSession,
|
||||||
|
deleteAutosave: deleteAutosaveSession,
|
||||||
|
};
|
||||||
153
inventory/src/types/importSession.ts
Normal file
153
inventory/src/types/importSession.ts
Normal 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
Reference in New Issue
Block a user