1144 lines
51 KiB
TypeScript
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">"{deleteTarget.name}"</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>
|
|
);
|
|
}
|