Solution: Updated frontend/src/components/ComprehensiveImportWizard.js:
- Increased timeout from 30s to 120s for large file uploads + R2 storage
- Added console error logging for debugging
Login Session Timeout Fix
1. frontend/src/utils/api.js
- Added BASENAME constant from environment variable
- Updated the 401 redirect to use ${BASENAME}/login?session=expired instead of just /login?session=expired
2. frontend/src/pages/Login.js
- Added basename constant from environment variable
- Updated URL cleanup to use ${basename}/login instead of just /login
1085 lines
38 KiB
JavaScript
1085 lines
38 KiB
JavaScript
import React, { useState, useCallback, useEffect } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from './ui/dialog';
|
|
import { Button } from './ui/button';
|
|
import { Card } from './ui/card';
|
|
import { Label } from './ui/label';
|
|
import { Checkbox } from './ui/checkbox';
|
|
import { Badge } from './ui/badge';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from './ui/select';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from './ui/table';
|
|
import {
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
} from './ui/tabs';
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from './ui/accordion';
|
|
import {
|
|
Upload,
|
|
Download,
|
|
FileSpreadsheet,
|
|
Users,
|
|
CreditCard,
|
|
Heart,
|
|
Receipt,
|
|
ClipboardList,
|
|
CheckCircle2,
|
|
XCircle,
|
|
AlertTriangle,
|
|
Loader2,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Info,
|
|
FileDown,
|
|
ExternalLink,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import api from '../utils/api';
|
|
|
|
const STEPS = [
|
|
{ id: 'templates', title: 'Download Templates', icon: Download },
|
|
{ id: 'upload', title: 'Upload Files', icon: Upload },
|
|
{ id: 'preview', title: 'Preview Data', icon: FileSpreadsheet },
|
|
{ id: 'options', title: 'Import Options', icon: Users },
|
|
{ id: 'execute', title: 'Execute Import', icon: CheckCircle2 },
|
|
];
|
|
|
|
const FILE_TYPES = {
|
|
users: { name: 'Users', icon: Users, color: 'text-blue-600', required: true },
|
|
subscriptions: { name: 'Subscriptions', icon: CreditCard, color: 'text-green-600', required: false },
|
|
donations: { name: 'Donations', icon: Heart, color: 'text-pink-600', required: false },
|
|
payments: { name: 'Payments', icon: Receipt, color: 'text-purple-600', required: false },
|
|
registration_data: { name: 'Custom Fields', icon: ClipboardList, color: 'text-orange-600', required: false },
|
|
};
|
|
|
|
const STATUS_COLORS = {
|
|
active: 'bg-green-100 text-green-800',
|
|
inactive: 'bg-gray-100 text-gray-800',
|
|
pending_email: 'bg-yellow-100 text-yellow-800',
|
|
pending_validation: 'bg-purple-100 text-purple-800',
|
|
pre_validated: 'bg-blue-100 text-blue-800',
|
|
payment_pending: 'bg-orange-100 text-orange-800',
|
|
canceled: 'bg-red-100 text-red-800',
|
|
expired: 'bg-red-100 text-red-800',
|
|
};
|
|
|
|
/**
|
|
* TemplateImportWizard - Template-based CSV import wizard
|
|
*
|
|
* Provides standardized CSV templates for importing:
|
|
* - Users (required)
|
|
* - Subscriptions (optional)
|
|
* - Donations (optional)
|
|
* - Payments (optional)
|
|
* - Custom Registration Fields (optional)
|
|
*/
|
|
const TemplateImportWizard = ({ open, onOpenChange, onSuccess }) => {
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [importJobId, setImportJobId] = useState(null);
|
|
|
|
// Templates state
|
|
const [templates, setTemplates] = useState([]);
|
|
const [templatesLoading, setTemplatesLoading] = useState(false);
|
|
|
|
// File upload state
|
|
const [files, setFiles] = useState({
|
|
users: null,
|
|
subscriptions: null,
|
|
donations: null,
|
|
payments: null,
|
|
registration_data: null,
|
|
});
|
|
|
|
// Upload/validation results
|
|
const [uploadSummary, setUploadSummary] = useState(null);
|
|
|
|
// Preview state
|
|
const [previewTab, setPreviewTab] = useState('users');
|
|
const [previewData, setPreviewData] = useState({});
|
|
const [previewPage, setPreviewPage] = useState(1);
|
|
const [previewTotalPages, setPreviewTotalPages] = useState(1);
|
|
|
|
// Import options
|
|
const [options, setOptions] = useState({
|
|
skip_notifications: true,
|
|
update_existing: false,
|
|
import_subscriptions: true,
|
|
import_donations: true,
|
|
import_payments: true,
|
|
import_registration_data: true,
|
|
skip_errors: true,
|
|
});
|
|
|
|
// Results state
|
|
const [importResults, setImportResults] = useState(null);
|
|
|
|
// Fetch templates on mount
|
|
useEffect(() => {
|
|
if (open && templates.length === 0) {
|
|
fetchTemplates();
|
|
}
|
|
}, [open]);
|
|
|
|
const fetchTemplates = async () => {
|
|
setTemplatesLoading(true);
|
|
try {
|
|
const response = await api.get('/admin/import/templates');
|
|
setTemplates(response.data);
|
|
} catch (error) {
|
|
toast.error('Failed to load templates');
|
|
} finally {
|
|
setTemplatesLoading(false);
|
|
}
|
|
};
|
|
|
|
// Reset wizard state
|
|
const resetWizard = useCallback(() => {
|
|
setCurrentStep(0);
|
|
setLoading(false);
|
|
setImportJobId(null);
|
|
setFiles({
|
|
users: null,
|
|
subscriptions: null,
|
|
donations: null,
|
|
payments: null,
|
|
registration_data: null,
|
|
});
|
|
setUploadSummary(null);
|
|
setPreviewData({});
|
|
setPreviewTab('users');
|
|
setPreviewPage(1);
|
|
setPreviewTotalPages(1);
|
|
setOptions({
|
|
skip_notifications: true,
|
|
update_existing: false,
|
|
import_subscriptions: true,
|
|
import_donations: true,
|
|
import_payments: true,
|
|
import_registration_data: true,
|
|
skip_errors: true,
|
|
});
|
|
setImportResults(null);
|
|
}, []);
|
|
|
|
// Handle dialog close
|
|
const handleClose = () => {
|
|
if (loading) return;
|
|
resetWizard();
|
|
onOpenChange(false);
|
|
};
|
|
|
|
// Download template
|
|
const downloadTemplate = async (templateType) => {
|
|
try {
|
|
const response = await api.get(`/admin/import/templates/${templateType}/download`, {
|
|
responseType: 'blob',
|
|
});
|
|
|
|
const blob = new Blob([response.data], { type: 'text/csv' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `${templateType}_template.csv`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
toast.success(`Downloaded ${templateType} template`);
|
|
} catch (error) {
|
|
toast.error(`Failed to download ${templateType} template`);
|
|
}
|
|
};
|
|
|
|
// Handle file selection
|
|
const handleFileChange = (fileType, file) => {
|
|
setFiles((prev) => ({
|
|
...prev,
|
|
[fileType]: file,
|
|
}));
|
|
};
|
|
|
|
// Step 2: Upload files
|
|
const handleUpload = async () => {
|
|
if (!files.users) {
|
|
toast.error('Please select a Users CSV file (required)');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('users_file', files.users);
|
|
|
|
if (files.subscriptions) {
|
|
formData.append('subscriptions_file', files.subscriptions);
|
|
}
|
|
if (files.donations) {
|
|
formData.append('donations_file', files.donations);
|
|
}
|
|
if (files.payments) {
|
|
formData.append('payments_file', files.payments);
|
|
}
|
|
if (files.registration_data) {
|
|
formData.append('registration_data_file', files.registration_data);
|
|
}
|
|
|
|
const response = await api.post('/admin/import/template/upload', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
});
|
|
|
|
setImportJobId(response.data.import_job_id);
|
|
setUploadSummary(response.data);
|
|
|
|
// Fetch preview for users
|
|
await fetchPreview(response.data.import_job_id, 'users', 1);
|
|
|
|
toast.success('Files uploaded and validated successfully');
|
|
setCurrentStep(2);
|
|
} catch (error) {
|
|
const message = error.response?.data?.detail?.message || error.response?.data?.detail || 'Failed to upload files';
|
|
toast.error(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Fetch preview data
|
|
const fetchPreview = async (jobId, fileType, page) => {
|
|
try {
|
|
const response = await api.get(`/admin/import/template/${jobId}/preview`, {
|
|
params: {
|
|
file_type: fileType,
|
|
page,
|
|
page_size: 10,
|
|
},
|
|
});
|
|
|
|
setPreviewData((prev) => ({
|
|
...prev,
|
|
[fileType]: response.data,
|
|
}));
|
|
setPreviewTotalPages(response.data.total_pages);
|
|
setPreviewPage(page);
|
|
} catch (error) {
|
|
toast.error('Failed to fetch preview');
|
|
}
|
|
};
|
|
|
|
// Handle preview tab change
|
|
const handlePreviewTabChange = async (tab) => {
|
|
setPreviewTab(tab);
|
|
setPreviewPage(1);
|
|
|
|
if (!previewData[tab] && importJobId) {
|
|
await fetchPreview(importJobId, tab, 1);
|
|
}
|
|
};
|
|
|
|
// Execute import
|
|
const handleExecute = async () => {
|
|
if (!importJobId) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await api.post(`/admin/import/template/${importJobId}/execute`, {
|
|
options,
|
|
});
|
|
|
|
setImportResults(response.data);
|
|
setCurrentStep(4);
|
|
toast.success('Import completed successfully');
|
|
|
|
if (onSuccess) {
|
|
onSuccess();
|
|
}
|
|
} catch (error) {
|
|
const message = error.response?.data?.detail || 'Failed to execute import';
|
|
toast.error(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Render step indicator
|
|
const renderStepIndicator = () => (
|
|
<div className="flex items-center justify-center mb-6">
|
|
{STEPS.map((step, index) => {
|
|
const Icon = step.icon;
|
|
const isActive = index === currentStep;
|
|
const isCompleted = index < currentStep;
|
|
|
|
return (
|
|
<React.Fragment key={step.id}>
|
|
<div className="flex flex-col items-center">
|
|
<div
|
|
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
|
isActive
|
|
? 'bg-brand-purple text-white'
|
|
: isCompleted
|
|
? 'bg-green-500 text-white'
|
|
: 'bg-gray-200 text-gray-500'
|
|
}`}
|
|
>
|
|
{isCompleted ? (
|
|
<CheckCircle2 className="h-5 w-5" />
|
|
) : (
|
|
<Icon className="h-5 w-5" />
|
|
)}
|
|
</div>
|
|
<span className={`text-xs mt-1 ${isActive ? 'text-brand-purple font-medium' : 'text-gray-500'}`}>
|
|
{step.title}
|
|
</span>
|
|
</div>
|
|
{index < STEPS.length - 1 && (
|
|
<div
|
|
className={`w-12 h-0.5 mx-2 ${
|
|
index < currentStep ? 'bg-green-500' : 'bg-gray-200'
|
|
}`}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
|
|
// Step 1: Download Templates
|
|
const renderTemplatesStep = () => (
|
|
<div className="space-y-4">
|
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<div className="flex items-start gap-3">
|
|
<Info className="h-5 w-5 text-blue-600 mt-0.5" />
|
|
<div>
|
|
<h4 className="font-medium text-blue-900">How Template Import Works</h4>
|
|
<p className="text-sm text-blue-700 mt-1">
|
|
1. Download the CSV templates below<br />
|
|
2. Fill in your data following the template format<br />
|
|
3. Upload your completed files in the next step<br />
|
|
4. Review and import your data
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3">
|
|
{templatesLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-brand-purple" />
|
|
</div>
|
|
) : (
|
|
templates.map((template) => {
|
|
const fileType = FILE_TYPES[template.type];
|
|
const Icon = fileType?.icon || FileSpreadsheet;
|
|
|
|
return (
|
|
<Card key={template.type} className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg bg-gray-100 ${fileType?.color || 'text-gray-600'}`}>
|
|
<Icon className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="font-medium">{template.name}</h4>
|
|
{template.required ? (
|
|
<Badge variant="destructive" className="text-xs">Required</Badge>
|
|
) : (
|
|
<Badge variant="secondary" className="text-xs">Optional</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">{template.description}</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{template.field_count} fields | Required: {template.required_fields.join(', ')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => downloadTemplate(template.type)}
|
|
>
|
|
<FileDown className="h-4 w-4 mr-2" />
|
|
Download
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end mt-6">
|
|
<Button onClick={() => setCurrentStep(1)}>
|
|
Continue to Upload
|
|
<ChevronRight className="h-4 w-4 ml-2" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Step 2: Upload Files
|
|
const renderUploadStep = () => (
|
|
<div className="space-y-4">
|
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5" />
|
|
<div>
|
|
<h4 className="font-medium text-yellow-900">Important</h4>
|
|
<p className="text-sm text-yellow-700 mt-1">
|
|
The Users CSV is required. Other files are optional and will be linked by email address.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3">
|
|
{Object.entries(FILE_TYPES).map(([key, config]) => {
|
|
const Icon = config.icon;
|
|
const file = files[key];
|
|
|
|
return (
|
|
<Card key={key} className={`p-4 ${file ? 'border-green-300 bg-green-50' : ''}`}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg ${file ? 'bg-green-100 text-green-600' : 'bg-gray-100'} ${config.color}`}>
|
|
<Icon className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="font-medium">{config.name}</h4>
|
|
{config.required ? (
|
|
<Badge variant="destructive" className="text-xs">Required</Badge>
|
|
) : (
|
|
<Badge variant="secondary" className="text-xs">Optional</Badge>
|
|
)}
|
|
</div>
|
|
{file ? (
|
|
<p className="text-sm text-green-600">{file.name}</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No file selected</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{file && (
|
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
|
)}
|
|
<label className="cursor-pointer">
|
|
<input
|
|
type="file"
|
|
accept=".csv"
|
|
className="hidden"
|
|
onChange={(e) => handleFileChange(key, e.target.files[0])}
|
|
/>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<span>
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
{file ? 'Change' : 'Select'}
|
|
</span>
|
|
</Button>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex justify-between mt-6">
|
|
<Button variant="outline" onClick={() => setCurrentStep(0)}>
|
|
<ChevronLeft className="h-4 w-4 mr-2" />
|
|
Back
|
|
</Button>
|
|
<Button onClick={handleUpload} disabled={loading || !files.users}>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Validating...
|
|
</>
|
|
) : (
|
|
<>
|
|
Upload & Validate
|
|
<ChevronRight className="h-4 w-4 ml-2" />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Step 3: Preview Data
|
|
const renderPreviewStep = () => {
|
|
const currentPreview = previewData[previewTab];
|
|
const validation = uploadSummary?.validation?.[previewTab];
|
|
const crossValidation = uploadSummary?.cross_validation;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Summary Cards */}
|
|
{crossValidation && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Card className="p-3 text-center">
|
|
<div className="text-2xl font-bold text-brand-purple">
|
|
{crossValidation.summary.total_users}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">Total Users</div>
|
|
</Card>
|
|
<Card className="p-3 text-center">
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{crossValidation.summary.new_users}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">New Users</div>
|
|
</Card>
|
|
<Card className="p-3 text-center">
|
|
<div className="text-2xl font-bold text-blue-600">
|
|
{crossValidation.summary.total_subscriptions}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">Subscriptions</div>
|
|
</Card>
|
|
<Card className="p-3 text-center">
|
|
<div className="text-2xl font-bold text-pink-600">
|
|
{crossValidation.summary.total_donations}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">Donations</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Warnings */}
|
|
{crossValidation?.warnings?.length > 0 && (
|
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
<h4 className="font-medium text-yellow-900 flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
Warnings
|
|
</h4>
|
|
<ul className="text-sm text-yellow-700 mt-2 space-y-1">
|
|
{crossValidation.warnings.map((warning, idx) => (
|
|
<li key={idx}>- {warning}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* Preview Tabs */}
|
|
<Tabs value={previewTab} onValueChange={handlePreviewTabChange}>
|
|
<TabsList className="grid grid-cols-5 w-full">
|
|
{Object.entries(FILE_TYPES).map(([key, config]) => {
|
|
const Icon = config.icon;
|
|
const hasData = uploadSummary?.files_uploaded?.[key];
|
|
|
|
return (
|
|
<TabsTrigger
|
|
key={key}
|
|
value={key}
|
|
disabled={!hasData}
|
|
className="flex items-center gap-1"
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
<span className="hidden md:inline">{config.name}</span>
|
|
</TabsTrigger>
|
|
);
|
|
})}
|
|
</TabsList>
|
|
|
|
<TabsContent value={previewTab} className="mt-4">
|
|
{/* Validation Status */}
|
|
{validation && (
|
|
<div className="flex items-center gap-4 mb-4 text-sm">
|
|
<span className="text-green-600">
|
|
<CheckCircle2 className="h-4 w-4 inline mr-1" />
|
|
{validation.valid_rows} valid
|
|
</span>
|
|
{validation.invalid_rows > 0 && (
|
|
<span className="text-red-600">
|
|
<XCircle className="h-4 w-4 inline mr-1" />
|
|
{validation.invalid_rows} invalid
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Data Table */}
|
|
{currentPreview?.rows?.length > 0 ? (
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<div className="overflow-x-auto max-h-64">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-12">#</TableHead>
|
|
{previewTab === 'users' && (
|
|
<>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Role</TableHead>
|
|
</>
|
|
)}
|
|
{previewTab === 'subscriptions' && (
|
|
<>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Plan</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Amount</TableHead>
|
|
</>
|
|
)}
|
|
{previewTab === 'donations' && (
|
|
<>
|
|
<TableHead>Email/Donor</TableHead>
|
|
<TableHead>Amount</TableHead>
|
|
<TableHead>Date</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
</>
|
|
)}
|
|
{previewTab === 'payments' && (
|
|
<>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Amount</TableHead>
|
|
<TableHead>Date</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
</>
|
|
)}
|
|
{previewTab === 'registration_data' && (
|
|
<>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Field Name</TableHead>
|
|
<TableHead>Value</TableHead>
|
|
</>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{currentPreview.rows.map((row, idx) => (
|
|
<TableRow key={idx}>
|
|
<TableCell className="text-muted-foreground">
|
|
{row._row_number || idx + 1}
|
|
</TableCell>
|
|
{previewTab === 'users' && (
|
|
<>
|
|
<TableCell className="font-mono text-sm">{row.email}</TableCell>
|
|
<TableCell>{row.first_name} {row.last_name}</TableCell>
|
|
<TableCell>
|
|
<Badge className={STATUS_COLORS[row.status] || 'bg-gray-100'}>
|
|
{row.status || 'active'}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>{row.role || 'member'}</TableCell>
|
|
</>
|
|
)}
|
|
{previewTab === 'subscriptions' && (
|
|
<>
|
|
<TableCell className="font-mono text-sm">{row.email}</TableCell>
|
|
<TableCell>{row.plan_name}</TableCell>
|
|
<TableCell>
|
|
<Badge className={STATUS_COLORS[row.status] || 'bg-gray-100'}>
|
|
{row.status || 'active'}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>${row.amount}</TableCell>
|
|
</>
|
|
)}
|
|
{previewTab === 'donations' && (
|
|
<>
|
|
<TableCell className="font-mono text-sm">
|
|
{row.email || row.donor_name || 'Anonymous'}
|
|
</TableCell>
|
|
<TableCell>${row.amount}</TableCell>
|
|
<TableCell>{row.date?.toString()}</TableCell>
|
|
<TableCell>{row.type || 'member'}</TableCell>
|
|
</>
|
|
)}
|
|
{previewTab === 'payments' && (
|
|
<>
|
|
<TableCell className="font-mono text-sm">{row.email}</TableCell>
|
|
<TableCell>${row.amount}</TableCell>
|
|
<TableCell>{row.date?.toString()}</TableCell>
|
|
<TableCell>{row.type || 'subscription'}</TableCell>
|
|
</>
|
|
)}
|
|
{previewTab === 'registration_data' && (
|
|
<>
|
|
<TableCell className="font-mono text-sm">{row.email}</TableCell>
|
|
<TableCell>{row.field_name}</TableCell>
|
|
<TableCell>{row.field_value}</TableCell>
|
|
</>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{currentPreview.total_pages > 1 && (
|
|
<div className="flex items-center justify-between px-4 py-2 border-t bg-gray-50">
|
|
<span className="text-sm text-muted-foreground">
|
|
Page {previewPage} of {currentPreview.total_pages}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={previewPage <= 1}
|
|
onClick={() => fetchPreview(importJobId, previewTab, previewPage - 1)}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={previewPage >= currentPreview.total_pages}
|
|
onClick={() => fetchPreview(importJobId, previewTab, previewPage + 1)}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No data available for this file type
|
|
</div>
|
|
)}
|
|
|
|
{/* Errors */}
|
|
{validation?.errors_preview?.length > 0 && (
|
|
<Accordion type="single" collapsible className="mt-4">
|
|
<AccordionItem value="errors">
|
|
<AccordionTrigger className="text-red-600">
|
|
<XCircle className="h-4 w-4 mr-2" />
|
|
{validation.invalid_rows} Validation Errors
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
<div className="space-y-2">
|
|
{validation.errors_preview.map((error, idx) => (
|
|
<div key={idx} className="p-2 bg-red-50 rounded text-sm">
|
|
<span className="font-medium">Row {error.row}:</span>{' '}
|
|
{error.errors.join(', ')}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<div className="flex justify-between mt-6">
|
|
<Button variant="outline" onClick={() => setCurrentStep(1)}>
|
|
<ChevronLeft className="h-4 w-4 mr-2" />
|
|
Back
|
|
</Button>
|
|
<Button onClick={() => setCurrentStep(3)} disabled={!uploadSummary?.can_proceed}>
|
|
Continue to Options
|
|
<ChevronRight className="h-4 w-4 ml-2" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Step 4: Import Options
|
|
const renderOptionsStep = () => (
|
|
<div className="space-y-6">
|
|
<Card className="p-4">
|
|
<h4 className="font-medium mb-4">Notification Settings</h4>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label>Skip all notifications</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Don't send welcome or password emails during import
|
|
</p>
|
|
</div>
|
|
<Checkbox
|
|
checked={options.skip_notifications}
|
|
onCheckedChange={(checked) =>
|
|
setOptions((prev) => ({ ...prev, skip_notifications: checked }))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4">
|
|
<h4 className="font-medium mb-4">User Import Settings</h4>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label>Update existing users</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
If email already exists, update the user instead of skipping
|
|
</p>
|
|
</div>
|
|
<Checkbox
|
|
checked={options.update_existing}
|
|
onCheckedChange={(checked) =>
|
|
setOptions((prev) => ({ ...prev, update_existing: checked }))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label>Skip errors and continue</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Continue importing even if some rows have errors
|
|
</p>
|
|
</div>
|
|
<Checkbox
|
|
checked={options.skip_errors}
|
|
onCheckedChange={(checked) =>
|
|
setOptions((prev) => ({ ...prev, skip_errors: checked }))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-4">
|
|
<h4 className="font-medium mb-4">Data Import Settings</h4>
|
|
<div className="space-y-3">
|
|
{uploadSummary?.files_uploaded?.subscriptions && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label>Import subscriptions</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Create subscription records from subscriptions.csv
|
|
</p>
|
|
</div>
|
|
<Checkbox
|
|
checked={options.import_subscriptions}
|
|
onCheckedChange={(checked) =>
|
|
setOptions((prev) => ({ ...prev, import_subscriptions: checked }))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{uploadSummary?.files_uploaded?.donations && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label>Import donations</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Create donation records from donations.csv
|
|
</p>
|
|
</div>
|
|
<Checkbox
|
|
checked={options.import_donations}
|
|
onCheckedChange={(checked) =>
|
|
setOptions((prev) => ({ ...prev, import_donations: checked }))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{uploadSummary?.files_uploaded?.payments && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label>Import payments</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Create payment records from payments.csv
|
|
</p>
|
|
</div>
|
|
<Checkbox
|
|
checked={options.import_payments}
|
|
onCheckedChange={(checked) =>
|
|
setOptions((prev) => ({ ...prev, import_payments: checked }))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{uploadSummary?.files_uploaded?.registration_data && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Label>Import custom fields</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Save custom registration data from registration_data.csv
|
|
</p>
|
|
</div>
|
|
<Checkbox
|
|
checked={options.import_registration_data}
|
|
onCheckedChange={(checked) =>
|
|
setOptions((prev) => ({ ...prev, import_registration_data: checked }))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<h4 className="font-medium text-blue-900">After Import</h4>
|
|
<p className="text-sm text-blue-700 mt-1">
|
|
All imported users will have <code className="bg-blue-100 px-1 rounded">force_password_change</code> enabled.
|
|
You can use the Bulk Password Reset feature to send login instructions after import.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-between mt-6">
|
|
<Button variant="outline" onClick={() => setCurrentStep(2)}>
|
|
<ChevronLeft className="h-4 w-4 mr-2" />
|
|
Back
|
|
</Button>
|
|
<Button onClick={handleExecute} disabled={loading}>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Importing...
|
|
</>
|
|
) : (
|
|
<>
|
|
Execute Import
|
|
<ChevronRight className="h-4 w-4 ml-2" />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Step 5: Results
|
|
const renderResultsStep = () => {
|
|
if (!importResults) return null;
|
|
|
|
const { results, errors, error_count } = importResults;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="text-center py-4">
|
|
<CheckCircle2 className="h-16 w-16 text-green-500 mx-auto mb-4" />
|
|
<h3 className="text-xl font-semibold">Import Complete</h3>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Card className="p-4 text-center">
|
|
<div className="text-3xl font-bold text-green-600">
|
|
{results.users.imported}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">Users Imported</div>
|
|
</Card>
|
|
<Card className="p-4 text-center">
|
|
<div className="text-3xl font-bold text-blue-600">
|
|
{results.users.updated}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">Users Updated</div>
|
|
</Card>
|
|
<Card className="p-4 text-center">
|
|
<div className="text-3xl font-bold text-purple-600">
|
|
{results.subscriptions.imported}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">Subscriptions</div>
|
|
</Card>
|
|
<Card className="p-4 text-center">
|
|
<div className="text-3xl font-bold text-pink-600">
|
|
{results.donations.imported}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">Donations</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{results.users.failed > 0 && (
|
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
<h4 className="font-medium text-yellow-900 flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
{results.users.failed} users failed to import
|
|
</h4>
|
|
</div>
|
|
)}
|
|
|
|
{error_count > 0 && (
|
|
<Accordion type="single" collapsible>
|
|
<AccordionItem value="errors">
|
|
<AccordionTrigger className="text-red-600">
|
|
<XCircle className="h-4 w-4 mr-2" />
|
|
{error_count} Errors
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
{errors.map((error, idx) => (
|
|
<div key={idx} className="p-2 bg-red-50 rounded text-sm">
|
|
{error.type && <Badge variant="outline" className="mr-2">{error.type}</Badge>}
|
|
<span className="font-medium">Row {error.row}:</span>{' '}
|
|
{error.error}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
)}
|
|
|
|
<div className="flex justify-center mt-6">
|
|
<Button onClick={handleClose}>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Render current step content
|
|
const renderStepContent = () => {
|
|
switch (currentStep) {
|
|
case 0:
|
|
return renderTemplatesStep();
|
|
case 1:
|
|
return renderUploadStep();
|
|
case 2:
|
|
return renderPreviewStep();
|
|
case 3:
|
|
return renderOptionsStep();
|
|
case 4:
|
|
return renderResultsStep();
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<FileSpreadsheet className="h-5 w-5" />
|
|
Template Import Wizard
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Import users and related data using standardized CSV templates
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{renderStepIndicator()}
|
|
{renderStepContent()}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default TemplateImportWizard;
|