ifixkart-admin/app/(admin)/migration/page.tsx

1144 lines
51 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, type ReactNode } from 'react';
import {
Upload,
FileSpreadsheet,
Image as ImageIcon,
RefreshCw,
CheckCircle2,
AlertTriangle,
ArrowRight,
Play,
RotateCcw,
Trash2,
X,
ZoomIn,
Clock,
Zap,
CheckCircle,
FileText,
User,
Layers,
ChevronRight,
} from 'lucide-react';
import { toast } from 'sonner';
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
import { getMediaUrl } from '@/services/api/config';
const getApiBase = (): string => {
if (typeof window !== 'undefined') {
const host = window.location.hostname;
if (host !== 'localhost' && host !== '127.0.0.1') {
return '/api/v1/migration';
}
}
const rawBackendUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const backendUrl = rawBackendUrl.replace(/\/api\/v1\/?$/, '').replace(/\/+$/, '');
return `${backendUrl}/api/v1/migration`;
};
type TabId = 'wizard' | 'media' | 'history';
type ImportMode = 'UPSERT' | 'CREATE_ONLY' | 'UPDATE_EXISTING' | 'SKIP_EXISTING';
const TABS: { id: TabId; label: string }[] = [
{ id: 'wizard', label: 'Import' },
{ id: 'media', label: 'Media Library' },
{ id: 'history', label: 'Import History & Audit' },
];
const WIZARD_STEPS = [
{ num: 1, title: 'Upload & Options' },
{ num: 2, title: 'Live Import Telemetry' },
];
const IMPORT_MODES: { mode: ImportMode; title: string; desc: string }[] = [
{ mode: 'UPSERT', title: 'Upsert (Recommended)', desc: 'Create new SKUs and update matching existing records.' },
{ mode: 'CREATE_ONLY', title: 'Create Only', desc: 'Insert new SKUs only. Error on existing SKU collisions.' },
{ mode: 'UPDATE_EXISTING', title: 'Update Existing', desc: 'Update matching SKUs only. Skip missing items.' },
{ mode: 'SKIP_EXISTING', title: 'Skip Existing', desc: 'Insert new records. Ignore existing SKUs.' },
];
const PIPELINE_PHASES = [
{ id: 'UPLOAD', label: 'Upload' },
{ id: 'MASTER_DATA', label: 'Master Data' },
{ id: 'PRODUCTS', label: 'Products & Variants' },
{ id: 'MEDIA_PROCESS', label: 'Media Process' },
{ id: 'MEDIA_LINK', label: 'Media Link' },
{ id: 'VERIFY', label: 'Validation' },
{ id: 'COMPLETED', label: 'Completed' },
];
function formatFileSize(bytes: number) {
if (!bytes) return '0 B';
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function formatDuration(seconds?: number) {
if (!seconds || seconds <= 0) return '0s';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
if (m === 0) return `${s}s`;
return `${m}m ${s}s`;
}
function statusBadgeClass(status?: string) {
const value = (status || '').toUpperCase();
if (value === 'COMPLETED') return 'bg-success text-white font-semibold';
if (value === 'FAILED') return 'bg-destructive text-white font-semibold';
if (value === 'RUNNING' || value === 'PROCESSING') return 'bg-warning text-white font-semibold animate-pulse';
if (value === 'ROLLED_BACK') return 'bg-muted text-muted-foreground font-semibold';
return 'bg-muted text-muted-foreground';
}
export default function MigrationPage() {
const [activeTab, setActiveTab] = useState<TabId>('wizard');
const [step, setStep] = useState<number>(1);
// File Upload State
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedMediaFile, setSelectedMediaFile] = useState<File | null>(null);
const [mediaStructure, setMediaStructure] = useState<'AUTO' | 'FLAT'>('AUTO');
const [importMode, setImportMode] = useState<ImportMode>('UPSERT');
const [uploadLoading, setUploadLoading] = useState<boolean>(false);
const [activeJobId, setActiveJobId] = useState<string | null>(null);
// Live Telemetry & Timer State
const [sseTelemetry, setSseTelemetry] = useState<any>(null);
const [startTime, setStartTime] = useState<number | null>(null);
const [elapsedSeconds, setElapsedSeconds] = useState<number>(0);
// Media Library State
const [mediaPreview, setMediaPreview] = useState<any[]>([]);
const [selectedMediaGroupIds, setSelectedMediaGroupIds] = useState<string[]>([]);
const [mediaDeleting, setMediaDeleting] = useState<string | null>(null);
const [bulkMediaDeleting, setBulkMediaDeleting] = useState<boolean>(false);
const [previewModalUrl, setPreviewModalUrl] = useState<string | null>(null);
// History & Audit State
const [batches, setBatches] = useState<any[]>([]);
const historyPager = useClientPagination(batches);
const [historyLoading, setHistoryLoading] = useState<boolean>(false);
const [rollbackLoading, setRollbackLoading] = useState<string | null>(null);
const [purgeLoading, setPurgeLoading] = useState<boolean>(false);
// Confirm Modal States
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const [showBulkDelete, setShowBulkDelete] = useState<boolean>(false);
const [rollbackTarget, setRollbackTarget] = useState<{ id: string } | null>(null);
const [showPurgeConfirm, setShowPurgeConfirm] = useState<boolean>(false);
const catalogInputRef = useRef<HTMLInputElement>(null);
const mediaInputRef = useRef<HTMLInputElement>(null);
// Elapsed Timer effect
useEffect(() => {
let timerInterval: any = null;
if (step === 2 && startTime && (!sseTelemetry || sseTelemetry.job_status === 'RUNNING' || sseTelemetry.job_status === 'QUEUED')) {
timerInterval = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
}
return () => {
if (timerInterval) clearInterval(timerInterval);
};
}, [step, startTime, sseTelemetry?.job_status]);
// Telemetry Polling Failsafe effect
useEffect(() => {
let pollInterval: any = null;
if (step === 2 && activeJobId && (!sseTelemetry || sseTelemetry.job_status === 'RUNNING' || sseTelemetry.job_status === 'QUEUED')) {
const pollStatus = async () => {
try {
const apiBase = getApiBase();
const res = await fetch(`${apiBase}/jobs/${activeJobId}/status`);
const data = await res.json();
if (data && (data.status === 'success' || data.job_status)) {
setSseTelemetry(data);
}
} catch (e) {
console.error('Failed to poll telemetry', e);
}
};
pollStatus();
pollInterval = setInterval(pollStatus, 1500);
}
return () => {
if (pollInterval) clearInterval(pollInterval);
};
}, [step, activeJobId, sseTelemetry?.job_status]);
// Fetch History Batches
const fetchBatches = async () => {
setHistoryLoading(true);
try {
const res = await fetch(`${getApiBase()}/batches`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
if (data.status === 'success') {
setBatches(data.batches || []);
}
} catch (e: any) {
console.error('Failed to fetch batches', e);
toast.error(`History error: ${e?.message || 'Failed to load import history'}`);
} finally {
setHistoryLoading(false);
}
};
// Fetch Media Groups Preview
const fetchMediaPreview = async () => {
try {
const res = await fetch(`${getApiBase()}/media-groups/preview`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
if (data.status === 'success') {
setMediaPreview(data.media_groups || []);
}
} catch (e: any) {
console.error('Failed to fetch media preview', e);
toast.error(`Media library error: ${e?.message || 'Failed to load media library'}`);
}
};
useEffect(() => {
if (activeTab === 'media') fetchMediaPreview();
else if (activeTab === 'history') fetchBatches();
}, [activeTab]);
// Streamlined 1-Click Start Import Handler
const handleStartImport = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile && !selectedMediaFile) {
toast.error('Select a catalog file (.xlsx / .csv) or a media ZIP archive');
return;
}
setUploadLoading(true);
const formData = new FormData();
if (selectedFile) formData.append('file', selectedFile);
if (selectedMediaFile) formData.append('media_file', selectedMediaFile);
formData.append('batch_type', 'PRODUCTS');
formData.append('import_mode', importMode);
formData.append('media_structure', mediaStructure);
try {
const apiBase = getApiBase();
const res = await fetch(`${apiBase}/upload`, { method: 'POST', body: formData });
let data: any = {};
try {
data = await res.json();
} catch (jsonErr) {
if (!res.ok) {
throw new Error(`Server returned HTTP ${res.status}: ${res.statusText || 'Upload Error'}`);
}
}
if (res.status >= 400 || data.status === 'error') {
const errorMsg = data.detail || data.message || `Upload failed (HTTP ${res.status})`;
toast.error(typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg));
setUploadLoading(false);
return;
}
const jobId = data.job_id || data.id;
if (!selectedFile && selectedMediaFile) {
await fetchMediaPreview();
setActiveTab('media');
toast.success(`Media archive uploaded & indexed successfully.`);
setSelectedMediaFile(null);
setUploadLoading(false);
return;
}
// Execute migration immediately for spreadsheet file
const execRes = await fetch(`${apiBase}/execute?job_id=${jobId}`, { method: 'POST' });
let execData: any = {};
try {
execData = await execRes.json();
} catch (jsonErr) {
if (!execRes.ok) {
throw new Error(`Execution trigger failed (HTTP ${execRes.status})`);
}
}
const activeId = execData.job_id || jobId;
setActiveJobId(activeId);
setStep(2);
setStartTime(Date.now());
setElapsedSeconds(0);
toast.success(`Import job #${String(activeId).slice(0, 8)} enqueued! Telemetry active.`);
// Connect SSE Live Stream
if (activeId) {
const eventSource = new EventSource(`${apiBase}/jobs/${activeId}/stream`);
eventSource.onmessage = (event) => {
try {
const streamData = JSON.parse(event.data);
setSseTelemetry(streamData);
if (['COMPLETED', 'FAILED', 'CANCELLED'].includes(streamData.job_status)) {
eventSource.close();
}
} catch (err) {
console.error('SSE JSON error', err);
}
};
eventSource.onerror = () => {
eventSource.close();
};
}
} catch (err: any) {
console.error('Import error:', err);
toast.error(err?.message || 'Failed to initiate bulk product import');
} finally {
setUploadLoading(false);
}
};
const resetWizard = () => {
setStep(1);
setSelectedFile(null);
setSelectedMediaFile(null);
setActiveJobId(null);
setSseTelemetry(null);
setStartTime(null);
setElapsedSeconds(0);
if (catalogInputRef.current) catalogInputRef.current.value = '';
if (mediaInputRef.current) mediaInputRef.current.value = '';
};
const handleConfirmDeleteMediaGroup = async () => {
if (!deleteTarget) return;
setMediaDeleting(deleteTarget.id);
try {
const res = await fetch(`${getApiBase()}/media-groups/${deleteTarget.id}`, { method: 'DELETE' });
const data = await res.json();
toast.success(data.message || 'Media key deleted');
setSelectedMediaGroupIds((ids) => ids.filter((id) => id !== deleteTarget.id));
setDeleteTarget(null);
fetchMediaPreview();
} catch {
toast.error('Failed to delete media key');
} finally {
setMediaDeleting(null);
}
};
const handleConfirmBulkDelete = async () => {
if (selectedMediaGroupIds.length === 0) return;
setBulkMediaDeleting(true);
try {
const res = await fetch(`${getApiBase()}/media-groups/bulk-delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_ids: selectedMediaGroupIds }),
});
const data = await res.json();
toast.success(data.message || `Deleted ${data.deleted_groups_count || selectedMediaGroupIds.length} media key(s)`);
setSelectedMediaGroupIds([]);
setShowBulkDelete(false);
fetchMediaPreview();
} catch {
toast.error('Bulk delete failed');
} finally {
setBulkMediaDeleting(false);
}
};
const handleConfirmRollback = async () => {
if (!rollbackTarget) return;
setRollbackLoading(rollbackTarget.id);
try {
const res = await fetch(`${getApiBase()}/rollback/${rollbackTarget.id}`, { method: 'POST' });
const data = await res.json();
toast.success(data.message || 'Rollback completed');
setRollbackTarget(null);
fetchBatches();
} catch {
toast.error('Rollback failed');
} finally {
setRollbackLoading(null);
}
};
const handleConfirmPurge = async () => {
setPurgeLoading(true);
try {
const res = await fetch(`${getApiBase()}/purge-all`, { method: 'POST' });
const data = await res.json();
toast.success(data.message || 'Catalog and media files purged');
setMediaPreview([]);
setSseTelemetry(null);
setShowPurgeConfirm(false);
fetchBatches();
} catch {
toast.error('Purge failed');
} finally {
setPurgeLoading(false);
}
};
const dataCardShell =
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0';
const primaryButton =
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50 transition-colors';
const secondaryButton =
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50 transition-colors';
const dangerButton =
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-destructive hover:bg-destructive/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50 transition-colors';
const renderConfirmDialog = ({
id,
title,
body,
confirmLabel,
loading,
onClose,
onConfirm,
}: {
id: string;
title: string;
body: ReactNode;
confirmLabel: string;
loading: boolean;
onClose: () => void;
onConfirm: () => void;
}) => (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-labelledby={id}
className="bg-card border border-border shadow-2xl w-full max-w-sm overflow-hidden crm-radius-none"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
<div className="flex items-start gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-destructive/10 text-destructive flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5" />
</div>
<div className="min-w-0">
<h3 id={id} className="text-[15px] font-semibold text-foreground">
{title}
</h3>
<p className="text-[13px] text-muted-foreground mt-1 leading-relaxed">{body}</p>
</div>
</div>
<button
type="button"
aria-label="Close dialog"
onClick={onClose}
disabled={loading}
className="w-8 h-8 rounded-full border border-border text-muted-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center shrink-0 disabled:opacity-50"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex items-center justify-end gap-2 px-5 pb-5 pt-3">
<button type="button" onClick={onClose} disabled={loading} className={secondaryButton}>
Cancel
</button>
<button type="button" onClick={onConfirm} disabled={loading} className={dangerButton}>
{loading ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
{loading ? 'Please wait...' : confirmLabel}
</button>
</div>
</div>
</div>
);
return (
<div className="flex flex-col gap-5 min-w-0">
{/* Top Title & Tab Switcher Bar */}
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-foreground">Bulk Product Import</h1>
{activeTab === 'media' && (
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
{mediaPreview.length}
</span>
)}
{activeTab === 'history' && (
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
{batches.length}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="crm-radius-toggle hidden sm:inline-flex items-center gap-1 border border-border bg-card p-1">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors ${
activeTab === tab.id
? 'bg-primary text-white'
: 'text-foreground hover:bg-muted'
}`}
>
{tab.label}
</button>
))}
</div>
<button
type="button"
onClick={() => {
if (activeTab === 'media') fetchMediaPreview();
else if (activeTab === 'history') fetchBatches();
}}
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
title="Reload"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Tab 1: 2-Step Import Wizard */}
{activeTab === 'wizard' && (
<div className={dataCardShell}>
{/* Step Progress Tracker Bar */}
<div className="px-5 py-4 border-b border-border">
<div className="flex items-center gap-4">
{WIZARD_STEPS.map((s, idx) => {
const isCurrent = step === s.num;
const isDone = step > s.num;
return (
<div key={s.num} className="flex items-center gap-2 min-w-0">
<div
className={`w-6 h-6 crm-radius-toggle flex items-center justify-center text-[11px] font-semibold ${
isDone
? 'bg-success text-white'
: isCurrent
? 'bg-primary text-white'
: 'bg-muted text-muted-foreground'
}`}
>
{isDone ? '✓' : s.num}
</div>
<span
className={`text-[13px] font-medium ${
isCurrent ? 'text-foreground font-semibold' : 'text-muted-foreground'
}`}
>
{s.title}
</span>
{idx < WIZARD_STEPS.length - 1 && (
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground mx-2" />
)}
</div>
);
})}
</div>
</div>
{/* STEP 1: Upload Files & Select Import Strategy */}
{step === 1 && (
<form onSubmit={handleStartImport} className="p-5 space-y-5">
<div>
<h2 className="text-[15px] font-semibold text-foreground">Upload Catalog Spreadsheet & Optional Media ZIP</h2>
<p className="text-[13px] text-muted-foreground mt-1">
Spreadsheet is required for product import. The system will automatically parse and link catalog items.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Product Catalog Drag/Drop Box */}
<div className="border border-dashed border-border crm-radius-card p-5 space-y-3">
<div className="w-10 h-10 crm-radius-icon bg-primary/10 text-primary flex items-center justify-center">
<FileSpreadsheet className="w-5 h-5" />
</div>
<div>
<p className="text-[13px] font-semibold text-foreground">Product catalog</p>
<p className="text-[12px] text-muted-foreground mt-0.5">CSV, XLSX, or ODS</p>
</div>
<input
ref={catalogInputRef}
type="file"
accept=".csv,.xlsx,.ods"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
className="hidden"
/>
<button type="button" onClick={() => catalogInputRef.current?.click()} className={secondaryButton}>
<Upload className="w-3.5 h-3.5" />
Choose file
</button>
{selectedFile && (
<p className="text-[12px] text-foreground font-medium truncate">
{selectedFile.name}
<span className="text-muted-foreground"> · {formatFileSize(selectedFile.size)}</span>
</p>
)}
</div>
{/* Media Archive ZIP Box */}
<div className="border border-dashed border-border crm-radius-card p-5 space-y-3">
<div className="w-10 h-10 crm-radius-icon bg-info/10 text-info flex items-center justify-center">
<ImageIcon className="w-5 h-5" />
</div>
<div>
<p className="text-[13px] font-semibold text-foreground">Media archive</p>
<p className="text-[12px] text-muted-foreground mt-0.5">ZIP with product images</p>
</div>
<input
ref={mediaInputRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={(e) => setSelectedMediaFile(e.target.files?.[0] || null)}
className="hidden"
/>
<div className="flex items-center gap-2">
<button type="button" onClick={() => mediaInputRef.current?.click()} className={secondaryButton}>
<Upload className="w-3.5 h-3.5" />
Choose file
</button>
<select
value={mediaStructure}
onChange={(e) => setMediaStructure(e.target.value as 'AUTO' | 'FLAT')}
className="h-8 px-2.5 bg-card border border-border crm-radius-control text-[12px] font-medium text-foreground focus:outline-none focus:border-primary cursor-pointer"
>
<option value="AUTO">Subfolder Mode (Default)</option>
<option value="FLAT">Flat File Mode (key__01.png)</option>
</select>
</div>
{selectedMediaFile && (
<p className="text-[12px] text-foreground font-medium truncate">
{selectedMediaFile.name}
<span className="text-muted-foreground"> · {formatFileSize(selectedMediaFile.size)}</span>
</p>
)}
</div>
</div>
{/* Import Strategy Cards */}
<div>
<p className="text-[13px] font-semibold text-foreground mb-2">Select Import Mode</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{IMPORT_MODES.map((item) => (
<button
key={item.mode}
type="button"
onClick={() => setImportMode(item.mode)}
className={`text-left p-3 crm-radius-card border cursor-pointer transition-colors ${
importMode === item.mode
? 'border-primary bg-primary/5'
: 'border-border bg-card hover:bg-muted/40'
}`}
>
<p className="text-[13px] font-semibold text-foreground">{item.title}</p>
<p className="text-[12px] text-muted-foreground mt-1 leading-relaxed">{item.desc}</p>
</button>
))}
</div>
</div>
<div className="flex justify-end pt-2 border-t border-border">
<button
type="submit"
disabled={(!selectedFile && !selectedMediaFile) || uploadLoading}
className={primaryButton}
>
{uploadLoading ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
{uploadLoading ? 'Uploading & Enqueuing...' : 'Start Import'}
</button>
</div>
</form>
)}
{/* STEP 2: Live Real-Time Telemetry & Progress Dashboard */}
{step === 2 && (
<div className="p-5 space-y-6">
{/* Header Status Card */}
<div className="flex flex-wrap items-center justify-between gap-4 p-4 border border-border crm-radius-card bg-muted/20">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 crm-radius-icon flex items-center justify-center shrink-0 ${
sseTelemetry && sseTelemetry.job_status === 'COMPLETED'
? 'bg-success/10 text-success'
: sseTelemetry && sseTelemetry.job_status === 'FAILED'
? 'bg-destructive/10 text-destructive'
: 'bg-primary/10 text-primary'
}`}
>
{sseTelemetry && sseTelemetry.job_status === 'COMPLETED' ? (
<CheckCircle2 className="w-5 h-5" />
) : sseTelemetry && sseTelemetry.job_status === 'FAILED' ? (
<X className="w-5 h-5" />
) : (
<RefreshCw className="w-5 h-5 animate-spin" />
)}
</div>
<div>
<h2 className="text-[15px] font-semibold text-foreground">
{sseTelemetry?.job_status === 'COMPLETED'
? 'Import Completed Successfully!'
: sseTelemetry?.job_status === 'FAILED'
? 'Import Job Failed'
: 'Live Migration Telemetry Streaming...'}
</h2>
<p className="text-[12px] text-muted-foreground mt-0.5 font-mono">
Job #{activeJobId ? String(activeJobId).slice(0, 12) : '—'}{' '}
{sseTelemetry?.current_phase ? `· Phase: ${sseTelemetry.current_phase}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`px-3 py-1 crm-radius-badge text-[11px] uppercase ${statusBadgeClass(sseTelemetry?.job_status || 'RUNNING')}`}>
{sseTelemetry?.job_status || 'RUNNING'}
</span>
</div>
</div>
{/* Progress Bar & ETA Metrics */}
{(() => {
const rawPct = sseTelemetry?.progress_percentage ||
(sseTelemetry?.total_records > 0 ? (sseTelemetry.processed_records / sseTelemetry.total_records) * 100 : 0);
const pct = Math.min(100, Math.max(0, Math.round(rawPct)));
const displayProcessed = Math.min(
sseTelemetry?.processed_records || 0,
sseTelemetry?.total_records || sseTelemetry?.processed_records || 0
);
const displayTotal = sseTelemetry?.total_records || displayProcessed;
return (
<div className="space-y-3 p-5 border border-primary/20 bg-primary/5 crm-radius-card">
<div className="flex justify-between items-center text-xs font-semibold text-foreground">
<span className="flex items-center gap-1.5 text-primary">
<Zap className="w-4 h-4" /> Live Progress: {pct}%
</span>
<span className="text-muted-foreground font-mono">
{displayProcessed.toLocaleString()} / {displayTotal.toLocaleString()} records processed
</span>
</div>
<div className="w-full bg-muted crm-radius-toggle h-3 overflow-hidden">
<div
className="bg-primary h-3 crm-radius-toggle transition-all duration-300 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex flex-wrap justify-between items-center text-[12px] text-muted-foreground pt-1">
<span>Rate: <strong className="text-foreground">{sseTelemetry?.processing_rate || 0}</strong> rec/sec</span>
<span>ETA: <strong className="text-foreground">{pct >= 100 || sseTelemetry?.job_status === 'COMPLETED' ? 'Complete' : (sseTelemetry?.eta_formatted || 'Calculating...')}</strong></span>
</div>
</div>
);
})()}
{/* Telemetry Metric Cards Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="border border-border crm-radius-card px-4 py-3 bg-card">
<div className="flex items-center gap-2 text-muted-foreground text-[11px] font-semibold uppercase">
<Clock className="w-3.5 h-3.5 text-primary" /> Elapsed Time
</div>
<p className="text-[20px] font-bold text-foreground mt-1 font-mono">{formatDuration(elapsedSeconds)}</p>
</div>
<div className="border border-border crm-radius-card px-4 py-3 bg-card">
<div className="flex items-center gap-2 text-muted-foreground text-[11px] font-semibold uppercase">
<Zap className="w-3.5 h-3.5 text-amber-500" /> Processing Speed
</div>
<p className="text-[20px] font-bold text-foreground mt-1 font-mono">
{sseTelemetry?.processing_rate || 0} <span className="text-xs font-normal text-muted-foreground">rec/s</span>
</p>
</div>
<div className="border border-border crm-radius-card px-4 py-3 bg-card">
<div className="flex items-center gap-2 text-muted-foreground text-[11px] font-semibold uppercase">
<CheckCircle className="w-3.5 h-3.5 text-success" /> Successful
</div>
<p className="text-[20px] font-bold text-success mt-1 font-mono">
{(sseTelemetry?.successful_records || 0).toLocaleString()}
</p>
</div>
<div className="border border-border crm-radius-card px-4 py-3 bg-card">
<div className="flex items-center gap-2 text-muted-foreground text-[11px] font-semibold uppercase">
<AlertTriangle className="w-3.5 h-3.5 text-destructive" /> Failed Records
</div>
<p className="text-[20px] font-bold text-destructive mt-1 font-mono">
{(sseTelemetry?.failed_records || 0).toLocaleString()}
</p>
</div>
</div>
{/* Active Pipeline Phase Tracker */}
<div className="space-y-2 border border-border crm-radius-card p-4 bg-card">
<p className="text-[13px] font-semibold text-foreground">Pipeline Execution Phase</p>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2 pt-2">
{PIPELINE_PHASES.map((ph) => {
const currentPhase = (sseTelemetry?.current_phase || 'PRODUCTS').toUpperCase();
const isCurrent = currentPhase === ph.id;
const isDone = sseTelemetry?.job_status === 'COMPLETED' || false;
return (
<div
key={ph.id}
className={`p-2 crm-radius-control border text-center transition-colors ${
isDone
? 'border-success bg-success/5 text-success font-semibold'
: isCurrent
? 'border-primary bg-primary/10 text-primary font-bold animate-pulse'
: 'border-border text-muted-foreground'
}`}
>
<p className="text-[11px] truncate">{ph.label}</p>
</div>
);
})}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-3 border-t border-border">
<button type="button" onClick={resetWizard} className={primaryButton}>
<RotateCcw className="w-3.5 h-3.5" />
Start New Import
</button>
<button type="button" onClick={() => setActiveTab('history')} className={secondaryButton}>
<FileText className="w-3.5 h-3.5" />
View History & Audit Matrix
</button>
</div>
</div>
)}
</div>
)}
{/* Tab 2: Paginated Media Library */}
{activeTab === 'media' && (
<div className={dataCardShell}>
<div className="px-5 py-4 border-b border-border flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-[15px] font-semibold text-foreground">Media Library</h2>
<p className="text-[13px] text-muted-foreground mt-0.5">Media keys indexed from uploaded ZIP archives.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{selectedMediaGroupIds.length > 0 && (
<button type="button" onClick={() => setShowBulkDelete(true)} disabled={bulkMediaDeleting} className={dangerButton}>
<Trash2 className="w-3.5 h-3.5" />
Delete selected ({selectedMediaGroupIds.length})
</button>
)}
{mediaPreview.length > 0 && (
<label className="inline-flex items-center gap-2 h-9 px-3 crm-radius-control border border-border bg-card text-[13px] text-foreground cursor-pointer">
<input
type="checkbox"
checked={selectedMediaGroupIds.length === mediaPreview.length && mediaPreview.length > 0}
onChange={() => {
if (selectedMediaGroupIds.length === mediaPreview.length) setSelectedMediaGroupIds([]);
else setSelectedMediaGroupIds(mediaPreview.map((mg) => mg.id));
}}
className="w-3.5 h-3.5 rounded border-border text-primary focus:ring-primary cursor-pointer"
/>
Select all
</label>
)}
</div>
</div>
{mediaPreview.length === 0 ? (
<div className="py-16 text-center">
<ImageIcon className="w-8 h-8 mx-auto text-muted-foreground/40 mb-2" />
<p className="text-[13px] text-muted-foreground">No media keys indexed yet. Upload a ZIP from the Import tab.</p>
</div>
) : (
<div className="p-5">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{mediaPreview.map((mg) => {
const hasImages = mg.media_assets && mg.media_assets.length > 0;
const isSelected = selectedMediaGroupIds.includes(mg.id);
return (
<div
key={mg.id}
className={`crm-radius-card border p-4 space-y-3 ${
isSelected ? 'border-primary bg-primary/5' : 'border-border bg-card'
}`}
>
<div className="flex items-start gap-2.5">
<input
type="checkbox"
checked={isSelected}
onChange={() => {
setSelectedMediaGroupIds((prev) =>
prev.includes(mg.id) ? prev.filter((id) => id !== mg.id) : [...prev, mg.id]
);
}}
className="w-3.5 h-3.5 mt-1 rounded border-border text-primary focus:ring-primary cursor-pointer shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-foreground truncate">{mg.media_key}</p>
<p className="text-[12px] text-muted-foreground truncate mt-0.5">
{mg.brand_name || '—'} · {mg.model_name || '—'} · {mg.variant_tag || 'Standard'}
</p>
</div>
<button
type="button"
onClick={() => setDeleteTarget({ id: mg.id, name: mg.media_key })}
disabled={mediaDeleting === mg.id}
className="w-8 h-8 crm-radius-toggle border border-border text-muted-foreground hover:text-destructive hover:bg-muted cursor-pointer flex items-center justify-center shrink-0"
title="Delete media key"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{hasImages ? (
<div className="grid grid-cols-3 gap-2">
{mg.media_assets.map((img: any) => {
const imgUrl = img.cdn_url ? getMediaUrl(img.cdn_url) : null;
return (
<button
key={img.id}
type="button"
onClick={() => imgUrl && setPreviewModalUrl(imgUrl)}
className="relative aspect-square bg-muted border border-border crm-radius-icon overflow-hidden cursor-pointer"
>
{imgUrl ? (
<>
<img src={imgUrl} alt={img.original_filename} className="w-full h-full object-cover" />
<span className="absolute inset-0 bg-black/30 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center text-white">
<ZoomIn className="w-4 h-4" />
</span>
</>
) : (
<span className="flex flex-col items-center justify-center h-full px-1">
<ImageIcon className="w-4 h-4 text-muted-foreground mb-1" />
<span className="text-[10px] text-muted-foreground line-clamp-2">{img.original_filename}</span>
</span>
)}
</button>
);
})}
</div>
) : (
<p className="text-[12px] text-muted-foreground py-3 text-center border border-dashed border-border crm-radius-control">
No image files for this key
</p>
)}
</div>
);
})}
</div>
</div>
)}
</div>
)}
{/* Tab 3: Detailed History & Audit Matrix */}
{activeTab === 'history' && (
<div className={dataCardShell}>
<div className="px-5 py-4 border-b border-border flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-[15px] font-semibold text-foreground">Import History & Audit Logs</h2>
<p className="text-[13px] text-muted-foreground mt-0.5">
Full historical audit trail of created SKUs, updated records, execution time, and rollback actions.
</p>
</div>
<button type="button" onClick={() => setShowPurgeConfirm(true)} disabled={purgeLoading} className={dangerButton}>
<Trash2 className="w-3.5 h-3.5" />
Purge Catalog
</button>
</div>
{historyLoading ? (
<div className="flex flex-col items-center justify-center py-16 gap-2.5">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
<span className="text-[13px] text-muted-foreground">Loading audit matrix...</span>
</div>
) : (
<div className="overflow-x-auto">
<table className="crm-data-table min-w-[850px]">
<thead>
<tr className="bg-muted/30 border-y border-border">
<th className="px-5 py-3 text-left text-[13px] font-semibold text-foreground">Batch ID</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-foreground">File & Mode</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-foreground">Record Action Breakdown</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-foreground">Timestamps & Duration</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-foreground">Status</th>
<th className="px-5 py-3 text-center text-[13px] font-semibold text-foreground">Actions</th>
</tr>
</thead>
<tbody className="bg-card">
{batches.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-12 text-[13px] text-muted-foreground">
No import batches found in audit log.
</td>
</tr>
) : (
historyPager.items.map((b) => (
<tr key={b.id} className="border-t border-border hover:bg-muted/10">
{/* Batch ID & User */}
<td className="px-5 py-3.5">
<p className="text-[13px] font-semibold text-foreground font-mono">
#{String(b.id).slice(0, 8)}
</p>
<p className="text-[11px] text-muted-foreground mt-0.5 flex items-center gap-1">
<User className="w-3 h-3 text-muted-foreground" /> {b.created_by || 'admin-user-01'}
</p>
</td>
{/* File Name & Import Mode */}
<td className="px-5 py-3.5">
<p className="text-[13px] font-medium text-foreground truncate max-w-[160px]" title={b.file_name || 'spreadsheet.xlsx'}>
{b.file_name || 'Catalog Import'}
</p>
<span className="inline-flex items-center px-2 py-0.5 mt-1 crm-radius-badge text-[10px] font-semibold bg-muted text-muted-foreground uppercase">
{b.import_mode || 'UPSERT'}
</span>
</td>
{/* Record Action Breakdown */}
<td className="px-5 py-3.5">
<p className="text-[13px] font-semibold text-foreground">
Total: {(b.total_records || 0).toLocaleString()}
</p>
<div className="flex flex-wrap gap-2 text-[11px] mt-1">
<span className="text-success font-medium">🟢 Ok: {(b.successful_records || 0).toLocaleString()}</span>
{b.failed_records > 0 && (
<span className="text-destructive font-medium">🔴 Failed: {b.failed_records}</span>
)}
</div>
</td>
{/* Timestamps & Duration */}
<td className="px-5 py-3.5">
<p className="text-[12px] font-mono text-foreground">
{b.started_at ? new Date(b.started_at).toLocaleTimeString() : (b.created_at ? new Date(b.created_at).toLocaleTimeString() : '—')}
</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
Duration: {formatDuration(b.duration_seconds || 1351)}
</p>
</td>
{/* Status Badge */}
<td className="px-5 py-3.5">
<span className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] ${statusBadgeClass(b.status)}`}>
{b.status || 'COMPLETED'}
</span>
</td>
{/* Action Buttons */}
<td className="px-5 py-3.5">
<div className="flex justify-center items-center gap-2">
{b.status !== 'ROLLED_BACK' ? (
<button
type="button"
onClick={() => setRollbackTarget({ id: b.id })}
disabled={rollbackLoading === b.id}
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control border border-border bg-card text-[12px] font-medium text-foreground hover:bg-muted cursor-pointer disabled:opacity-50"
>
{rollbackLoading === b.id ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
Rollback
</button>
) : (
<span className="text-[12px] text-muted-foreground">—</span>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
<TablePagination {...historyPager} />
</div>
)}
{/* Media Preview Lightbox Modal */}
{previewModalUrl && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
onClick={() => setPreviewModalUrl(null)}
>
<div
className="relative bg-card border border-border shadow-2xl max-w-3xl w-full overflow-hidden crm-radius-none"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setPreviewModalUrl(null)}
className="absolute top-3 right-3 z-10 w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:bg-muted cursor-pointer flex items-center justify-center"
>
<X className="w-4 h-4" />
</button>
<img src={getMediaUrl(previewModalUrl)} alt="Preview" className="max-h-[80vh] w-full object-contain bg-muted" />
</div>
</div>
)}
{/* Confirmation Modals */}
{deleteTarget &&
renderConfirmDialog({
id: 'delete-media-title',
title: 'Delete Media Key',
body: (
<>
Delete <span className="font-semibold text-foreground">&quot;{deleteTarget.name}&quot;</span> and its image files?
</>
),
confirmLabel: 'Delete',
loading: mediaDeleting === deleteTarget.id,
onClose: () => {
if (!mediaDeleting) setDeleteTarget(null);
},
onConfirm: handleConfirmDeleteMediaGroup,
})}
{showBulkDelete &&
renderConfirmDialog({
id: 'bulk-delete-media-title',
title: 'Delete Selected Media',
body: `Delete ${selectedMediaGroupIds.length} media key(s) and their image files?`,
confirmLabel: 'Delete Selected',
loading: bulkMediaDeleting,
onClose: () => {
if (!bulkMediaDeleting) setShowBulkDelete(false);
},
onConfirm: handleConfirmBulkDelete,
})}
{rollbackTarget &&
renderConfirmDialog({
id: 'rollback-title',
title: 'Rollback Import Batch',
body: 'This reverts database changes from this import batch.',
confirmLabel: 'Rollback',
loading: rollbackLoading === rollbackTarget.id,
onClose: () => {
if (!rollbackLoading) setRollbackTarget(null);
},
onConfirm: handleConfirmRollback,
})}
{showPurgeConfirm &&
renderConfirmDialog({
id: 'purge-title',
title: 'Purge Catalog Database',
body: 'This permanently deletes all products, variants, attributes, media groups, and image files.',
confirmLabel: 'Purge All',
loading: purgeLoading,
onClose: () => {
if (!purgeLoading) setShowPurgeConfirm(false);
},
onConfirm: handleConfirmPurge,
})}
</div>
);
}