Files
membership-fe/src/components/TemplateImportWizard.js
Andika 08c8dd3913 Frontend Upload Improvements
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
2026-02-04 22:52:09 +07:00

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;