988 lines
42 KiB
JavaScript
988 lines
42 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
|
|
import { Button } from './ui/button';
|
|
import { Card } from './ui/card';
|
|
import { Badge } from './ui/badge';
|
|
import { Checkbox } from './ui/checkbox';
|
|
import { Input } from './ui/input';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
|
import { Progress } from './ui/progress';
|
|
import { Alert, AlertDescription } from './ui/alert';
|
|
import { toast } from 'sonner';
|
|
import api from '../utils/api';
|
|
import {
|
|
Upload,
|
|
FileCheck,
|
|
CheckCircle,
|
|
Eye,
|
|
Play,
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
AlertTriangle,
|
|
Trash2,
|
|
FileDown,
|
|
Users,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Loader2
|
|
} from 'lucide-react';
|
|
|
|
/**
|
|
* WordPress Import Wizard Component
|
|
*
|
|
* A comprehensive 6-step wizard for importing WordPress users to LOAF platform.
|
|
* Features:
|
|
* - CSV upload and analysis
|
|
* - Interactive status review and adjustment
|
|
* - Preview before import
|
|
* - Real-time import progress
|
|
* - Full rollback capability
|
|
* - Error reporting
|
|
*/
|
|
export default function WordPressImportWizard({ open, onOpenChange, onSuccess }) {
|
|
// Wizard state
|
|
const [currentStep, setCurrentStep] = useState(1);
|
|
const [importJobId, setImportJobId] = useState(null);
|
|
|
|
// Data state
|
|
const [uploadedFile, setUploadedFile] = useState(null);
|
|
const [analysisResult, setAnalysisResult] = useState(null);
|
|
const [previewData, setPreviewData] = useState([]);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
// Override state
|
|
const [statusOverrides, setStatusOverrides] = useState({});
|
|
const [selectedRows, setSelectedRows] = useState(new Set());
|
|
|
|
// Import execution state
|
|
const [importing, setImporting] = useState(false);
|
|
const [importProgress, setImportProgress] = useState(0);
|
|
const [importResults, setImportResults] = useState(null);
|
|
|
|
// UI state
|
|
const [uploading, setUploading] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Step definitions
|
|
const steps = [
|
|
{ number: 1, title: 'Upload CSV', icon: Upload },
|
|
{ number: 2, title: 'Field Mapping', icon: FileCheck },
|
|
{ number: 3, title: 'Review Status', icon: CheckCircle },
|
|
{ number: 4, title: 'Preview', icon: Eye },
|
|
{ number: 5, title: 'Execute', icon: Play },
|
|
{ number: 6, title: 'Results', icon: CheckCircle2 }
|
|
];
|
|
|
|
// Reset wizard state when dialog opens/closes
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setTimeout(() => {
|
|
setCurrentStep(1);
|
|
setImportJobId(null);
|
|
setUploadedFile(null);
|
|
setAnalysisResult(null);
|
|
setPreviewData([]);
|
|
setStatusOverrides({});
|
|
setSelectedRows(new Set());
|
|
setImporting(false);
|
|
setImportProgress(0);
|
|
setImportResults(null);
|
|
}, 300); // Wait for dialog close animation
|
|
}
|
|
}, [open]);
|
|
|
|
// ============================================================================
|
|
// Step Navigation
|
|
// ============================================================================
|
|
|
|
const canProceed = () => {
|
|
switch (currentStep) {
|
|
case 1:
|
|
return uploadedFile && analysisResult;
|
|
case 2:
|
|
return true; // Field mapping auto-detected
|
|
case 3:
|
|
return true; // Status review is optional
|
|
case 4:
|
|
return true; // Preview is informational
|
|
case 5:
|
|
return !importing;
|
|
case 6:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleNext = () => {
|
|
if (currentStep < 6 && canProceed()) {
|
|
if (currentStep === 3) {
|
|
// Load preview data when moving from step 3 to 4
|
|
loadPreviewData(1);
|
|
}
|
|
setCurrentStep(currentStep + 1);
|
|
}
|
|
};
|
|
|
|
const handleBack = () => {
|
|
if (currentStep > 1) {
|
|
setCurrentStep(currentStep - 1);
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Step 1: Upload CSV
|
|
// ============================================================================
|
|
|
|
const handleFileSelect = (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
if (!file.name.endsWith('.csv')) {
|
|
toast.error('Please upload a CSV file');
|
|
return;
|
|
}
|
|
setUploadedFile(file);
|
|
}
|
|
};
|
|
|
|
const handleUpload = async () => {
|
|
if (!uploadedFile) return;
|
|
|
|
setUploading(true);
|
|
const formData = new FormData();
|
|
formData.append('file', uploadedFile);
|
|
|
|
try {
|
|
const response = await api.post('/admin/import/upload-csv', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
|
|
setImportJobId(response.data.import_job_id);
|
|
setAnalysisResult(response.data);
|
|
toast.success(`CSV analyzed: ${response.data.valid_rows} valid rows, ${response.data.warnings} warnings`);
|
|
|
|
// Auto-advance to next step
|
|
setTimeout(() => setCurrentStep(2), 500);
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.detail || 'Failed to upload CSV');
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Step 3: Review & Adjust Status
|
|
// ============================================================================
|
|
|
|
const loadPreviewData = async (page = 1) => {
|
|
if (!importJobId) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await api.get(`/admin/import/${importJobId}/preview`, {
|
|
params: { page, page_size: 50 }
|
|
});
|
|
|
|
setPreviewData(response.data.rows);
|
|
setCurrentPage(response.data.page);
|
|
setTotalPages(response.data.total_pages);
|
|
} catch (error) {
|
|
toast.error('Failed to load preview data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (currentStep === 3 && importJobId && previewData.length === 0) {
|
|
loadPreviewData(1);
|
|
}
|
|
}, [currentStep, importJobId]);
|
|
|
|
const handleStatusOverride = (rowNum, status) => {
|
|
setStatusOverrides(prev => ({
|
|
...prev,
|
|
[rowNum]: { status }
|
|
}));
|
|
};
|
|
|
|
const handleBulkStatusChange = (status) => {
|
|
const newOverrides = { ...statusOverrides };
|
|
selectedRows.forEach(rowNum => {
|
|
newOverrides[rowNum] = { status };
|
|
});
|
|
setStatusOverrides(newOverrides);
|
|
toast.success(`Updated ${selectedRows.size} users to ${status}`);
|
|
};
|
|
|
|
const toggleRowSelection = (rowNum) => {
|
|
setSelectedRows(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(rowNum)) {
|
|
newSet.delete(rowNum);
|
|
} else {
|
|
newSet.add(rowNum);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
const toggleSelectAll = () => {
|
|
if (selectedRows.size === previewData.length) {
|
|
setSelectedRows(new Set());
|
|
} else {
|
|
setSelectedRows(new Set(previewData.map(row => row.row_number)));
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Step 5: Execute Import
|
|
// ============================================================================
|
|
|
|
const handleExecuteImport = async () => {
|
|
setImporting(true);
|
|
setCurrentStep(5);
|
|
|
|
try {
|
|
// Start import
|
|
const response = await api.post(`/admin/import/${importJobId}/execute`, {
|
|
overrides: statusOverrides,
|
|
options: {
|
|
send_password_emails: true,
|
|
skip_errors: true
|
|
}
|
|
});
|
|
|
|
setImportResults(response.data);
|
|
toast.success(`Import completed: ${response.data.successful_rows} users imported`);
|
|
|
|
// Move to results step
|
|
setCurrentStep(6);
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.detail || 'Import failed');
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
};
|
|
|
|
// Poll for import progress
|
|
useEffect(() => {
|
|
if (currentStep === 5 && importing && importJobId) {
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
const response = await api.get(`/admin/import/${importJobId}/status`);
|
|
setImportProgress(response.data.progress_percent);
|
|
} catch (error) {
|
|
console.error('Failed to fetch import status:', error);
|
|
}
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [currentStep, importing, importJobId]);
|
|
|
|
// ============================================================================
|
|
// Step 6: Rollback
|
|
// ============================================================================
|
|
|
|
const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false);
|
|
const [confirmText, setConfirmText] = useState('');
|
|
|
|
const handleRollback = async () => {
|
|
try {
|
|
await api.post(`/admin/import/${importJobId}/rollback`, { confirm: true });
|
|
toast.success(`Rolled back ${importResults.successful_rows} users`);
|
|
onOpenChange(false);
|
|
if (onSuccess) onSuccess();
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.detail || 'Rollback failed');
|
|
}
|
|
};
|
|
|
|
const handleDownloadErrors = async () => {
|
|
try {
|
|
const response = await api.get(`/admin/import/${importJobId}/errors/download`, {
|
|
responseType: 'blob'
|
|
});
|
|
|
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', `import_errors_${importJobId}.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
|
|
toast.success('Error report downloaded');
|
|
} catch (error) {
|
|
toast.error('Failed to download error report');
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Status Badge Component
|
|
// ============================================================================
|
|
|
|
const StatusBadge = ({ status }) => {
|
|
const colors = {
|
|
active: 'bg-green-100 text-green-800 border-green-300',
|
|
pre_validated: 'bg-blue-100 text-blue-800 border-blue-300',
|
|
payment_pending: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
|
inactive: 'bg-gray-100 text-gray-800 border-gray-300'
|
|
};
|
|
|
|
return (
|
|
<Badge variant="outline" className={colors[status] || 'bg-gray-100 text-gray-800'}>
|
|
{status.replace('_', ' ')}
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// Render Step Content
|
|
// ============================================================================
|
|
|
|
const renderStepContent = () => {
|
|
switch (currentStep) {
|
|
case 1:
|
|
return <Step1Upload />;
|
|
case 2:
|
|
return <Step2FieldMapping />;
|
|
case 3:
|
|
return <Step3ReviewStatus />;
|
|
case 4:
|
|
return <Step4Preview />;
|
|
case 5:
|
|
return <Step5Execute />;
|
|
case 6:
|
|
return <Step6Results />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Step 1: Upload CSV
|
|
// ============================================================================
|
|
|
|
const Step1Upload = () => (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Upload WordPress CSV Export</h3>
|
|
<p className="text-sm text-brand-purple ">
|
|
Select the WordPress user export CSV file. The file will be analyzed for data quality issues.
|
|
</p>
|
|
</div>
|
|
|
|
<Card className="p-6 border-2 border-dashed border-[var(--neutral-800)] bg-[var(--lavender-400)]">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Upload className="h-12 w-12 text-brand-purple " />
|
|
<div className="text-center">
|
|
<Input
|
|
type="file"
|
|
accept=".csv"
|
|
onChange={handleFileSelect}
|
|
className="max-w-xs"
|
|
/>
|
|
{uploadedFile && (
|
|
<p className="text-sm text-brand-purple mt-2">
|
|
Selected: {uploadedFile.name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{uploadedFile && !analysisResult && (
|
|
<Button
|
|
onClick={handleUpload}
|
|
disabled={uploading}
|
|
className="w-full bg-brand-purple hover:bg-[var(--purple-ink)]"
|
|
>
|
|
{uploading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Analyzing CSV...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
Upload and Analyze
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
|
|
{analysisResult && (
|
|
<Card className="p-6 bg-green-50 border-green-200">
|
|
<h4 className="font-semibold text-green-900 mb-4">Analysis Complete</h4>
|
|
<div className="grid md:grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-sm text-green-700">Total Rows</p>
|
|
<p className="text-2xl font-semibold text-green-900">{analysisResult.total_rows}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-green-700">Valid Rows</p>
|
|
<p className="text-2xl font-semibold text-green-900">{analysisResult.valid_rows}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-yellow-700">Warnings</p>
|
|
<p className="text-2xl font-semibold text-yellow-900">{analysisResult.warnings}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-red-700">Errors</p>
|
|
<p className="text-2xl font-semibold text-red-900">{analysisResult.errors}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{analysisResult.data_quality && (
|
|
<div className="mt-4 pt-4 border-t border-green-300">
|
|
<h5 className="text-sm font-semibold text-green-900 mb-2">Data Quality Issues:</h5>
|
|
<ul className="text-sm text-green-800 space-y-1">
|
|
{analysisResult.data_quality.invalid_dob > 0 && (
|
|
<li>• {analysisResult.data_quality.invalid_dob} invalid dates of birth</li>
|
|
)}
|
|
{analysisResult.data_quality.missing_phone > 0 && (
|
|
<li>• {analysisResult.data_quality.missing_phone} missing phone numbers</li>
|
|
)}
|
|
{analysisResult.data_quality.duplicate_email > 0 && (
|
|
<li>• {analysisResult.data_quality.duplicate_email} duplicate emails</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// ============================================================================
|
|
// Step 2: Field Mapping
|
|
// ============================================================================
|
|
|
|
const Step2FieldMapping = () => (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Field Mapping</h3>
|
|
<p className="text-sm text-brand-purple ">
|
|
WordPress fields have been automatically mapped to LOAF platform fields.
|
|
</p>
|
|
</div>
|
|
|
|
<Card className="p-6">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>WordPress Field</TableHead>
|
|
<TableHead>→</TableHead>
|
|
<TableHead>LOAF Field</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
<TableRow>
|
|
<TableCell className="font-mono text-sm">user_email</TableCell>
|
|
<TableCell>→</TableCell>
|
|
<TableCell className="font-mono text-sm">email</TableCell>
|
|
</TableRow>
|
|
<TableRow>
|
|
<TableCell className="font-mono text-sm">first_name</TableCell>
|
|
<TableCell>→</TableCell>
|
|
<TableCell className="font-mono text-sm">first_name</TableCell>
|
|
</TableRow>
|
|
<TableRow>
|
|
<TableCell className="font-mono text-sm">last_name</TableCell>
|
|
<TableCell>→</TableCell>
|
|
<TableCell className="font-mono text-sm">last_name</TableCell>
|
|
</TableRow>
|
|
<TableRow>
|
|
<TableCell className="font-mono text-sm">cell_phone</TableCell>
|
|
<TableCell>→</TableCell>
|
|
<TableCell className="font-mono text-sm">phone</TableCell>
|
|
</TableRow>
|
|
<TableRow>
|
|
<TableCell className="font-mono text-sm">date_of_birth</TableCell>
|
|
<TableCell>→</TableCell>
|
|
<TableCell className="font-mono text-sm">date_of_birth</TableCell>
|
|
</TableRow>
|
|
<TableRow>
|
|
<TableCell className="font-mono text-sm">wp_capabilities</TableCell>
|
|
<TableCell>→</TableCell>
|
|
<TableCell className="font-mono text-sm">role + status</TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
</Card>
|
|
|
|
<Alert className="bg-blue-50 border-blue-200">
|
|
<AlertCircle className="h-4 w-4 text-blue-600" />
|
|
<AlertDescription className="text-blue-800">
|
|
WordPress roles will be automatically converted:
|
|
<ul className="mt-2 ml-4 space-y-1">
|
|
<li>• <code>loaf_admin</code> → admin (active)</li>
|
|
<li>• <code>loaf_treasure</code> → finance (active)</li>
|
|
<li>• <code>administrator</code> → superadmin (active)</li>
|
|
<li>• <code>pms_subscription_plan_63</code> → member</li>
|
|
</ul>
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
);
|
|
|
|
// ============================================================================
|
|
// Step 3: Review & Adjust Status (KEY FEATURE)
|
|
// ============================================================================
|
|
|
|
const Step3ReviewStatus = () => (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Review & Adjust User Status</h3>
|
|
<p className="text-sm text-brand-purple ">
|
|
Review suggested status mappings and override as needed before import.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Bulk edit toolbar */}
|
|
<Card className="p-4 bg-[var(--lavender-400)] border-[var(--neutral-800)]">
|
|
<div className="flex items-center gap-4">
|
|
<Checkbox
|
|
checked={selectedRows.size === previewData.length && previewData.length > 0}
|
|
onCheckedChange={toggleSelectAll}
|
|
/>
|
|
<span className="text-sm text-brand-purple font-medium">
|
|
{selectedRows.size > 0 ? `${selectedRows.size} selected` : 'Select all'}
|
|
</span>
|
|
{selectedRows.size > 0 && (
|
|
<Select onValueChange={handleBulkStatusChange}>
|
|
<SelectTrigger className="w-48">
|
|
<SelectValue placeholder="Change status to..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
|
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
|
<SelectItem value="inactive">Inactive</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Data table */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-8 w-8 animate-spin text-brand-purple " />
|
|
</div>
|
|
) : (
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-[var(--lavender-400)]">
|
|
<TableHead className="w-12">
|
|
<Checkbox checked={false} />
|
|
</TableHead>
|
|
<TableHead>Row</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>WP Role</TableHead>
|
|
<TableHead>Suggested Status</TableHead>
|
|
<TableHead>Override Status</TableHead>
|
|
<TableHead>Issues</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{previewData.map((row) => (
|
|
<TableRow key={row.row_number}>
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={selectedRows.has(row.row_number)}
|
|
onCheckedChange={() => toggleRowSelection(row.row_number)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">{row.row_number}</TableCell>
|
|
<TableCell className="text-sm">{row.email}</TableCell>
|
|
<TableCell className="text-sm">
|
|
{row.first_name} {row.last_name}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)]">
|
|
{row.wordpress_roles?.join(', ') || 'N/A'}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<StatusBadge status={row.suggested_status} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Select
|
|
value={statusOverrides[row.row_number]?.status || row.suggested_status}
|
|
onValueChange={(value) => handleStatusOverride(row.row_number, value)}
|
|
>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
|
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
|
<SelectItem value="inactive">Inactive</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell>
|
|
{row.warnings?.map((warning, idx) => (
|
|
<Badge
|
|
key={idx}
|
|
variant="outline"
|
|
className="text-orange-600 border-orange-300 mr-1"
|
|
>
|
|
<AlertCircle className="h-3 w-3 mr-1" />
|
|
{warning}
|
|
</Badge>
|
|
))}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-brand-purple ">
|
|
Page {currentPage} of {totalPages}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => loadPreviewData(currentPage - 1)}
|
|
disabled={currentPage === 1 || loading}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => loadPreviewData(currentPage + 1)}
|
|
disabled={currentPage === totalPages || loading}
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// ============================================================================
|
|
// Step 4: Preview
|
|
// ============================================================================
|
|
|
|
const Step4Preview = () => {
|
|
const overrideCount = Object.keys(statusOverrides).length;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Import Preview</h3>
|
|
<p className="text-sm text-brand-purple ">
|
|
Review the final import settings before execution.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid md:grid-cols-3 gap-4">
|
|
<Card className="p-6">
|
|
<p className="text-sm text-brand-purple ">Total Users</p>
|
|
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{analysisResult?.total_rows}</p>
|
|
</Card>
|
|
<Card className="p-6">
|
|
<p className="text-sm text-brand-purple ">Status Overrides</p>
|
|
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{overrideCount}</p>
|
|
</Card>
|
|
<Card className="p-6">
|
|
<p className="text-sm text-brand-purple ">Expected Imports</p>
|
|
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{analysisResult?.valid_rows}</p>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card className="p-6">
|
|
<h4 className="font-semibold text-[var(--purple-ink)] mb-4">Import Options</h4>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
|
<span className="text-sm text-brand-purple ">Send password reset emails to all imported users</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
|
<span className="text-sm text-brand-purple ">Skip rows with errors and continue import</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
|
<span className="text-sm text-brand-purple ">Full rollback capability available after import</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{overrideCount > 0 && (
|
|
<Alert className="bg-yellow-50 border-yellow-200">
|
|
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
|
<AlertDescription className="text-yellow-800">
|
|
You have overridden {overrideCount} user status{overrideCount > 1 ? 'es' : ''}.
|
|
These will be applied during import.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// Step 5: Execute
|
|
// ============================================================================
|
|
|
|
const Step5Execute = () => (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
|
|
{importing ? 'Import in Progress...' : 'Ready to Import'}
|
|
</h3>
|
|
<p className="text-sm text-brand-purple ">
|
|
{importing
|
|
? 'Please wait while users are imported. This may take a few minutes.'
|
|
: 'Click "Start Import" to begin importing users.'}
|
|
</p>
|
|
</div>
|
|
|
|
{importing && (
|
|
<div className="space-y-4">
|
|
<Progress value={importProgress} className="w-full" />
|
|
<p className="text-center text-sm text-brand-purple ">
|
|
{importProgress.toFixed(1)}% complete
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{!importing && !importResults && (
|
|
<Button
|
|
onClick={handleExecuteImport}
|
|
className="w-full bg-brand-purple hover:bg-[var(--purple-ink)] py-6 text-lg"
|
|
>
|
|
<Play className="mr-2 h-5 w-5" />
|
|
Start Import
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// ============================================================================
|
|
// Step 6: Results & Rollback
|
|
// ============================================================================
|
|
|
|
const Step6Results = () => (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Import Complete</h3>
|
|
<p className="text-sm text-brand-purple ">
|
|
Review the import results and download error reports if needed.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Stats cards */}
|
|
<div className="grid md:grid-cols-3 gap-4">
|
|
<Card className="p-6 bg-green-50 border-green-200">
|
|
<p className="text-sm text-green-700">Successful Imports</p>
|
|
<p className="text-4xl font-semibold text-green-900">{importResults?.successful_rows || 0}</p>
|
|
</Card>
|
|
<Card className="p-6 bg-red-50 border-red-200">
|
|
<p className="text-sm text-red-700">Failed Imports</p>
|
|
<p className="text-4xl font-semibold text-red-900">{importResults?.failed_rows || 0}</p>
|
|
</Card>
|
|
<Card className="p-6 bg-blue-50 border-blue-200">
|
|
<p className="text-sm text-blue-700">Password Emails Sent</p>
|
|
<p className="text-4xl font-semibold text-blue-900">{importResults?.password_emails_queued || 0}</p>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex gap-4 justify-between flex-wrap">
|
|
<div className="flex gap-4 flex-wrap">
|
|
{importResults?.failed_rows > 0 && (
|
|
<Button onClick={handleDownloadErrors} variant="outline">
|
|
<FileDown className="h-4 w-4 mr-2" />
|
|
Download Error Report
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={() => {
|
|
onOpenChange(false);
|
|
if (onSuccess) onSuccess();
|
|
}}
|
|
variant="outline"
|
|
>
|
|
<Users className="h-4 w-4 mr-2" />
|
|
View Imported Members
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Rollback button (prominent, red) */}
|
|
{importResults?.successful_rows > 0 && (
|
|
<Button
|
|
onClick={() => setRollbackConfirmOpen(true)}
|
|
variant="destructive"
|
|
className="bg-red-600 hover:bg-red-700"
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
Rollback Import ({importResults.successful_rows} users)
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Rollback confirmation dialog */}
|
|
<Dialog open={rollbackConfirmOpen} onOpenChange={setRollbackConfirmOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-3 bg-red-100 rounded-full">
|
|
<AlertTriangle className="h-6 w-6 text-red-600" />
|
|
</div>
|
|
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]">
|
|
Confirm Rollback
|
|
</DialogTitle>
|
|
</div>
|
|
<DialogDescription className="text-brand-purple ">
|
|
This will permanently delete{' '}
|
|
<strong>{importResults?.successful_rows} users</strong> that were imported.
|
|
This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 my-4">
|
|
<p className="text-sm text-red-800 font-medium mb-2">
|
|
Type "DELETE {importResults?.successful_rows} USERS" to confirm:
|
|
</p>
|
|
<Input
|
|
value={confirmText}
|
|
onChange={(e) => setConfirmText(e.target.value)}
|
|
className="mt-2"
|
|
placeholder={`DELETE ${importResults?.successful_rows} USERS`}
|
|
/>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button onClick={() => setRollbackConfirmOpen(false)} variant="outline">
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleRollback}
|
|
disabled={confirmText !== `DELETE ${importResults?.successful_rows} USERS`}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
>
|
|
Yes, Delete {importResults?.successful_rows} Users
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
|
|
// ============================================================================
|
|
// Main Render
|
|
// ============================================================================
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]">
|
|
WordPress Import Wizard
|
|
</DialogTitle>
|
|
<DialogDescription className="text-brand-purple ">
|
|
Import WordPress users with interactive status review and full rollback capability
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Step indicator */}
|
|
<div className="flex items-center justify-between mb-6 px-4">
|
|
{steps.map((step, index) => {
|
|
const StepIcon = step.icon;
|
|
const isCompleted = currentStep > step.number;
|
|
const isCurrent = currentStep === step.number;
|
|
|
|
return (
|
|
<React.Fragment key={step.number}>
|
|
<div className="flex flex-col items-center">
|
|
<div
|
|
className={`
|
|
w-10 h-10 rounded-full flex items-center justify-center
|
|
${isCurrent ? 'bg-brand-purple text-white' : ''}
|
|
${isCompleted ? 'bg-green-600 text-white' : ''}
|
|
${!isCurrent && !isCompleted ? 'bg-gray-200 text-gray-600' : ''}
|
|
`}
|
|
>
|
|
{isCompleted ? (
|
|
<CheckCircle className="h-5 w-5" />
|
|
) : (
|
|
<StepIcon className="h-5 w-5" />
|
|
)}
|
|
</div>
|
|
<p className={`text-xs mt-1 ${isCurrent ? 'font-semibold text-[var(--purple-ink)]' : 'text-gray-600'}`}>
|
|
{step.title}
|
|
</p>
|
|
</div>
|
|
{index < steps.length - 1 && (
|
|
<div className={`flex-1 h-0.5 mx-2 ${isCompleted ? 'bg-green-600' : 'bg-gray-300'}`} />
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Step content */}
|
|
<div className="py-6">
|
|
{renderStepContent()}
|
|
</div>
|
|
|
|
{/* Navigation footer */}
|
|
<DialogFooter className="flex justify-between items-center">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleBack}
|
|
disabled={currentStep === 1 || importing}
|
|
>
|
|
<ChevronLeft className="h-4 w-4 mr-2" />
|
|
Back
|
|
</Button>
|
|
|
|
{currentStep < 5 && (
|
|
<Button
|
|
onClick={handleNext}
|
|
disabled={!canProceed()}
|
|
className="bg-brand-purple hover:bg-[var(--purple-ink)]"
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4 ml-2" />
|
|
</Button>
|
|
)}
|
|
|
|
{currentStep === 6 && (
|
|
<Button
|
|
onClick={() => {
|
|
onOpenChange(false);
|
|
if (onSuccess) onSuccess();
|
|
}}
|
|
className="bg-brand-purple hover:bg-[var(--purple-ink)]"
|
|
>
|
|
Close
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|