diff --git a/.env.development b/.env.development index 6ec835a..3886372 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,3 @@ REACT_APP_BACKEND_URL=http://localhost:8000 -REACT_APP_BASENAME=/membership -PUBLIC_URL=/membership +REACT_APP_BASENAME=/ +PUBLIC_URL=/ diff --git a/src/components/ComprehensiveImportWizard.js b/src/components/ComprehensiveImportWizard.js new file mode 100644 index 0000000..d1b985e --- /dev/null +++ b/src/components/ComprehensiveImportWizard.js @@ -0,0 +1,1138 @@ +import React, { useState, useCallback } 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from './ui/table'; +import { + Upload, + FileSpreadsheet, + Users, + CreditCard, + Receipt, + CheckCircle2, + XCircle, + AlertTriangle, + Loader2, + ChevronLeft, + ChevronRight, + Download, + RotateCcw, + Mail, + MailX, + Info, +} from 'lucide-react'; +import { toast } from 'sonner'; +import api from '../utils/api'; + +const STEPS = [ + { 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 STATUS_COLORS = { + active: 'bg-green-100 text-green-800', + pre_validated: 'bg-blue-100 text-blue-800', + payment_pending: 'bg-yellow-100 text-yellow-800', + pending_validation: 'bg-purple-100 text-purple-800', + inactive: 'bg-gray-100 text-gray-800', + expired: 'bg-red-100 text-red-800', +}; + +const ROLE_COLORS = { + superadmin: 'bg-red-100 text-red-800', + admin: 'bg-orange-100 text-orange-800', + finance: 'bg-blue-100 text-blue-800', + member: 'bg-green-100 text-green-800', + guest: 'bg-gray-100 text-gray-800', +}; + +/** + * ComprehensiveImportWizard - Multi-file WordPress import wizard + * + * Supports importing: + * - WordPress Users CSV (required) + * - PMS Members CSV (optional) - subscription data + * - PMS Payments CSV (optional) - payment history + */ +const ComprehensiveImportWizard = ({ open, onOpenChange, onSuccess }) => { + const [currentStep, setCurrentStep] = useState(0); + const [loading, setLoading] = useState(false); + const [importJobId, setImportJobId] = useState(null); + + // File upload state + const [usersFile, setUsersFile] = useState(null); + const [membersFile, setMembersFile] = useState(null); + const [paymentsFile, setPaymentsFile] = useState(null); + + // Preview state + const [uploadSummary, setUploadSummary] = useState(null); + const [previewData, setPreviewData] = useState([]); + const [previewPage, setPreviewPage] = useState(1); + const [previewTotalPages, setPreviewTotalPages] = useState(1); + const [filterErrors, setFilterErrors] = useState(false); + const [filterWarnings, setFilterWarnings] = useState(false); + + // Override state + const [overrides, setOverrides] = useState({}); + + // Import options + const [options, setOptions] = useState({ + skip_notifications: true, + send_welcome_emails: false, + send_password_emails: false, + import_subscriptions: true, + import_payment_history: true, + skip_errors: true, + }); + + // Results state + const [importResults, setImportResults] = useState(null); + + // Reset wizard state + const resetWizard = useCallback(() => { + setCurrentStep(0); + setLoading(false); + setImportJobId(null); + setUsersFile(null); + setMembersFile(null); + setPaymentsFile(null); + setUploadSummary(null); + setPreviewData([]); + setPreviewPage(1); + setPreviewTotalPages(1); + setFilterErrors(false); + setFilterWarnings(false); + setOverrides({}); + setOptions({ + skip_notifications: true, + send_welcome_emails: false, + send_password_emails: false, + import_subscriptions: true, + import_payment_history: true, + skip_errors: true, + }); + setImportResults(null); + }, []); + + // Handle dialog close + const handleClose = () => { + if (loading) return; + resetWizard(); + onOpenChange(false); + }; + + // Step 1: Upload files + const handleUpload = async () => { + if (!usersFile) { + toast.error('Please select a WordPress Users CSV file'); + return; + } + + setLoading(true); + try { + const formData = new FormData(); + formData.append('users_file', usersFile); + if (membersFile) { + formData.append('members_file', membersFile); + } + if (paymentsFile) { + formData.append('payments_file', paymentsFile); + } + + const response = await api.post('/admin/import/comprehensive/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000, // 2 minutes for large file uploads + R2 storage + }); + + setImportJobId(response.data.import_job_id); + setUploadSummary(response.data); + + // Fetch preview data + await fetchPreview(response.data.import_job_id, 1); + + toast.success('Files uploaded and analyzed successfully'); + setCurrentStep(1); + } catch (error) { + console.error('[ComprehensiveImport] Upload failed:', error); + const message = error.response?.data?.detail || + error.message || + 'Failed to upload files'; + toast.error(message); + } finally { + setLoading(false); + } + }; + + // Fetch preview data + const fetchPreview = async (jobId, page) => { + try { + const response = await api.get(`/admin/import/comprehensive/${jobId}/preview`, { + params: { + page, + page_size: 20, + filter_errors: filterErrors, + filter_warnings: filterWarnings, + }, + }); + + setPreviewData(response.data.rows); + setPreviewPage(response.data.page); + setPreviewTotalPages(response.data.total_pages); + } catch (error) { + toast.error('Failed to load preview data'); + } + }; + + // Handle page change + const handlePageChange = async (newPage) => { + if (newPage < 1 || newPage > previewTotalPages) return; + setLoading(true); + await fetchPreview(importJobId, newPage); + setLoading(false); + }; + + // Handle filter change + const handleFilterChange = async () => { + if (!importJobId) return; + setLoading(true); + await fetchPreview(importJobId, 1); + setLoading(false); + }; + + // Handle override change + const handleOverride = (rowNum, field, value) => { + setOverrides((prev) => ({ + ...prev, + [rowNum]: { + ...prev[rowNum], + [field]: value, + }, + })); + }; + + // Execute import + const handleExecute = async () => { + if (!importJobId) return; + + setLoading(true); + try { + const response = await api.post(`/admin/import/comprehensive/${importJobId}/execute`, { + overrides, + options, + }); + + setImportResults(response.data); + toast.success( + `Import completed: ${response.data.successful_rows} users imported` + ); + setCurrentStep(3); + } catch (error) { + const message = error.response?.data?.detail || 'Import failed'; + toast.error(message); + } finally { + setLoading(false); + } + }; + + // Render step content + const renderStepContent = () => { + switch (currentStep) { + case 0: + return renderUploadStep(); + case 1: + return renderPreviewStep(); + case 2: + return renderOptionsStep(); + case 3: + return renderResultsStep(); + default: + return null; + } + }; + + // Step 1: Upload + const renderUploadStep = () => ( +
+
+

+ Upload your WordPress export CSV files. The Users file is required, + while Members and Payments files are optional but recommended for + complete data migration. +

+
+ + {/* Users File (Required) */} + +
+
+ +
+
+ +

+ User profiles, contact info, directory settings, newsletter + preferences +

+ setUsersFile(e.target.files[0])} + className="block w-full text-sm text-brand-purple file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-purple file:text-white hover:file:bg-[var(--purple-ink)] cursor-pointer" + /> + {usersFile && ( +

+ + {usersFile.name} +

+ )} +
+
+
+ + {/* Members File (Optional) */} + +
+
+ +
+
+ +

+ Subscription plans, start/end dates, membership status +

+ setMembersFile(e.target.files[0])} + className="block w-full text-sm text-brand-purple file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-[var(--lavender-500)] file:text-brand-purple hover:file:bg-[var(--lavender-300)] cursor-pointer" + /> + {membersFile && ( +

+ + {membersFile.name} +

+ )} +
+
+
+ + {/* Payments File (Optional) */} + +
+
+ +
+
+ +

+ Payment history, transaction records, amounts paid +

+ setPaymentsFile(e.target.files[0])} + className="block w-full text-sm text-brand-purple file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-[var(--lavender-500)] file:text-brand-purple hover:file:bg-[var(--lavender-300)] cursor-pointer" + /> + {paymentsFile && ( +

+ + {paymentsFile.name} +

+ )} +
+
+
+
+ ); + + // Step 2: Preview + const renderPreviewStep = () => ( +
+ {/* Summary Cards */} + {uploadSummary && ( +
+ +
+ + + Users + +
+
+ {uploadSummary.users?.valid || 0} + + {' '} + / {uploadSummary.users?.total || 0} + +
+

+ {uploadSummary.users?.warnings || 0} warnings,{' '} + {uploadSummary.users?.errors || 0} errors +

+
+ + +
+ + + Subscriptions + +
+
+ {uploadSummary.members?.matched || 0} + + {' '} + / {uploadSummary.members?.total || 0} + +
+

+ {uploadSummary.members?.unmatched || 0} unmatched +

+
+ + +
+ + + Payments + +
+
+ {uploadSummary.payments?.matched || 0} +
+

+ $ + {( + (uploadSummary.payments?.total_amount_cents || 0) / 100 + ).toLocaleString()}{' '} + total +

+
+
+ )} + + {/* Filters */} +
+
+ { + setFilterErrors(checked); + setTimeout(handleFilterChange, 0); + }} + /> + +
+
+ { + setFilterWarnings(checked); + setTimeout(handleFilterChange, 0); + }} + /> + +
+
+ + {/* Preview Table */} +
+ + + + + Row + + + Email + + + Name + + + Role + + + Status + + + Subscription + + + Issues + + + + + {previewData.map((row) => ( + 0 + ? 'bg-red-50' + : row.warnings?.length > 0 + ? 'bg-yellow-50' + : '' + } + > + + {row.row_number} + + {row.email} + + {row.first_name} {row.last_name} + + + + + + + + + {row.has_subscription ? ( + + Yes + + ) : ( + + No + + )} + + +
+ {row.errors?.length > 0 && ( + + + + )} + {row.warnings?.length > 0 && ( + + + + )} + {!row.errors?.length && !row.warnings?.length && ( + + )} +
+
+
+ ))} +
+
+
+ + {/* Pagination */} +
+

+ Page {previewPage} of {previewTotalPages} +

+
+ + +
+
+
+ ); + + // Step 3: Options + const renderOptionsStep = () => ( +
+ {/* Notification Options */} + +

+ + Email Notifications +

+ +
+
+ + setOptions((prev) => ({ + ...prev, + skip_notifications: checked, + send_welcome_emails: checked ? false : prev.send_welcome_emails, + send_password_emails: checked + ? false + : prev.send_password_emails, + })) + } + /> +
+ +

+ No emails will be sent to imported users. You can send a bulk + password reset later from the admin panel. +

+
+
+ + {!options.skip_notifications && ( + <> +
+ + setOptions((prev) => ({ + ...prev, + send_welcome_emails: checked, + })) + } + /> + +
+ +
+ + setOptions((prev) => ({ + ...prev, + send_password_emails: checked, + })) + } + /> + +
+ + )} +
+
+ + {/* Data Import Options */} + +

+ + Data Import Options +

+ +
+
+ + setOptions((prev) => ({ + ...prev, + import_subscriptions: checked, + })) + } + /> +
+ +

+ Create subscription records from Members CSV data +

+
+
+ +
+ + setOptions((prev) => ({ + ...prev, + import_payment_history: checked, + })) + } + /> +
+ +

+ Include payment history from Payments CSV in subscription notes +

+
+
+ +
+ + setOptions((prev) => ({ + ...prev, + skip_errors: checked, + })) + } + /> +
+ +

+ Skip rows with errors and continue importing valid rows +

+
+
+
+
+ + {/* Summary */} + +
+ +
+

Import Summary

+

+ Ready to import {uploadSummary?.users?.valid || 0} users + {options.import_subscriptions && + uploadSummary?.members?.matched > 0 && + ` with ${uploadSummary.members.matched} subscriptions`} + . All imported users will have{' '} + + force_password_change + {' '} + enabled. +

+
+
+
+
+ ); + + // Step 4: Results + const renderResultsStep = () => ( +
+ {importResults && ( + <> + {/* Success Banner */} +
+ {importResults.failed_rows === 0 ? ( + + ) : ( + + )} +

+ {importResults.failed_rows === 0 + ? 'Import Completed Successfully!' + : 'Import Completed with Some Errors'} +

+
+ + {/* Results Grid */} +
+ +

+ {importResults.successful_rows} +

+

Users Imported

+
+ +

+ {importResults.failed_rows} +

+

Failed

+
+ +

+ {importResults.created_subscriptions} +

+

Subscriptions

+
+ +

+ {importResults.emails_sent} +

+

Emails Sent

+
+
+ + {/* Error List */} + {importResults.errors?.length > 0 && ( + +

+ Import Errors ({importResults.errors.length}) +

+
+ {importResults.errors.slice(0, 20).map((error, idx) => ( +
+ + Row {error.row}: + {' '} + {error.error} + {error.email && ( + ({error.email}) + )} +
+ ))} + {importResults.errors.length > 20 && ( +

+ ...and {importResults.errors.length - 20} more errors +

+ )} +
+
+ )} + + {/* Next Steps */} + +

+ Next Steps +

+ +
+ + )} +
+ ); + + return ( + + + + + WordPress Import Wizard + + + Import users, subscriptions, and payment history from WordPress + + + + {/* Step Indicator */} +
+ {STEPS.map((step, idx) => { + const StepIcon = step.icon; + const isActive = idx === currentStep; + const isCompleted = idx < currentStep; + + return ( + +
+
+ {isCompleted ? ( + + ) : ( + + )} +
+ + {step.title} + +
+ {idx < STEPS.length - 1 && ( +
+ )} + + ); + })} +
+ + {/* Step Content */} +
{renderStepContent()}
+ + + {/* Back Button */} + {currentStep > 0 && currentStep < 3 && ( + + )} + + {/* Cancel Button */} + {currentStep < 3 && ( + + )} + + {/* Action Buttons */} + {currentStep === 0 && ( + + )} + + {currentStep === 1 && ( + + )} + + {currentStep === 2 && ( + + )} + + {currentStep === 3 && ( + + )} + + +
+ ); +}; + +export default ComprehensiveImportWizard; diff --git a/src/components/TemplateImportWizard.js b/src/components/TemplateImportWizard.js new file mode 100644 index 0000000..c468456 --- /dev/null +++ b/src/components/TemplateImportWizard.js @@ -0,0 +1,1084 @@ +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 = () => ( +
+ {STEPS.map((step, index) => { + const Icon = step.icon; + const isActive = index === currentStep; + const isCompleted = index < currentStep; + + return ( + +
+
+ {isCompleted ? ( + + ) : ( + + )} +
+ + {step.title} + +
+ {index < STEPS.length - 1 && ( +
+ )} + + ); + })} +
+ ); + + // Step 1: Download Templates + const renderTemplatesStep = () => ( +
+
+
+ +
+

How Template Import Works

+

+ 1. Download the CSV templates below
+ 2. Fill in your data following the template format
+ 3. Upload your completed files in the next step
+ 4. Review and import your data +

+
+
+
+ +
+ {templatesLoading ? ( +
+ +
+ ) : ( + templates.map((template) => { + const fileType = FILE_TYPES[template.type]; + const Icon = fileType?.icon || FileSpreadsheet; + + return ( + +
+
+
+ +
+
+
+

{template.name}

+ {template.required ? ( + Required + ) : ( + Optional + )} +
+

{template.description}

+

+ {template.field_count} fields | Required: {template.required_fields.join(', ')} +

+
+
+ +
+
+ ); + }) + )} +
+ +
+ +
+
+ ); + + // Step 2: Upload Files + const renderUploadStep = () => ( +
+
+
+ +
+

Important

+

+ The Users CSV is required. Other files are optional and will be linked by email address. +

+
+
+
+ +
+ {Object.entries(FILE_TYPES).map(([key, config]) => { + const Icon = config.icon; + const file = files[key]; + + return ( + +
+
+
+ +
+
+
+

{config.name}

+ {config.required ? ( + Required + ) : ( + Optional + )} +
+ {file ? ( +

{file.name}

+ ) : ( +

No file selected

+ )} +
+
+
+ {file && ( + + )} + +
+
+
+ ); + })} +
+ +
+ + +
+
+ ); + + // Step 3: Preview Data + const renderPreviewStep = () => { + const currentPreview = previewData[previewTab]; + const validation = uploadSummary?.validation?.[previewTab]; + const crossValidation = uploadSummary?.cross_validation; + + return ( +
+ {/* Summary Cards */} + {crossValidation && ( +
+ +
+ {crossValidation.summary.total_users} +
+
Total Users
+
+ +
+ {crossValidation.summary.new_users} +
+
New Users
+
+ +
+ {crossValidation.summary.total_subscriptions} +
+
Subscriptions
+
+ +
+ {crossValidation.summary.total_donations} +
+
Donations
+
+
+ )} + + {/* Warnings */} + {crossValidation?.warnings?.length > 0 && ( +
+

+ + Warnings +

+
    + {crossValidation.warnings.map((warning, idx) => ( +
  • - {warning}
  • + ))} +
+
+ )} + + {/* Preview Tabs */} + + + {Object.entries(FILE_TYPES).map(([key, config]) => { + const Icon = config.icon; + const hasData = uploadSummary?.files_uploaded?.[key]; + + return ( + + + {config.name} + + ); + })} + + + + {/* Validation Status */} + {validation && ( +
+ + + {validation.valid_rows} valid + + {validation.invalid_rows > 0 && ( + + + {validation.invalid_rows} invalid + + )} +
+ )} + + {/* Data Table */} + {currentPreview?.rows?.length > 0 ? ( +
+
+ + + + # + {previewTab === 'users' && ( + <> + Email + Name + Status + Role + + )} + {previewTab === 'subscriptions' && ( + <> + Email + Plan + Status + Amount + + )} + {previewTab === 'donations' && ( + <> + Email/Donor + Amount + Date + Type + + )} + {previewTab === 'payments' && ( + <> + Email + Amount + Date + Type + + )} + {previewTab === 'registration_data' && ( + <> + Email + Field Name + Value + + )} + + + + {currentPreview.rows.map((row, idx) => ( + + + {row._row_number || idx + 1} + + {previewTab === 'users' && ( + <> + {row.email} + {row.first_name} {row.last_name} + + + {row.status || 'active'} + + + {row.role || 'member'} + + )} + {previewTab === 'subscriptions' && ( + <> + {row.email} + {row.plan_name} + + + {row.status || 'active'} + + + ${row.amount} + + )} + {previewTab === 'donations' && ( + <> + + {row.email || row.donor_name || 'Anonymous'} + + ${row.amount} + {row.date?.toString()} + {row.type || 'member'} + + )} + {previewTab === 'payments' && ( + <> + {row.email} + ${row.amount} + {row.date?.toString()} + {row.type || 'subscription'} + + )} + {previewTab === 'registration_data' && ( + <> + {row.email} + {row.field_name} + {row.field_value} + + )} + + ))} + +
+
+ + {/* Pagination */} + {currentPreview.total_pages > 1 && ( +
+ + Page {previewPage} of {currentPreview.total_pages} + +
+ + +
+
+ )} +
+ ) : ( +
+ No data available for this file type +
+ )} + + {/* Errors */} + {validation?.errors_preview?.length > 0 && ( + + + + + {validation.invalid_rows} Validation Errors + + +
+ {validation.errors_preview.map((error, idx) => ( +
+ Row {error.row}:{' '} + {error.errors.join(', ')} +
+ ))} +
+
+
+
+ )} +
+
+ +
+ + +
+
+ ); + }; + + // Step 4: Import Options + const renderOptionsStep = () => ( +
+ +

Notification Settings

+
+
+
+ +

+ Don't send welcome or password emails during import +

+
+ + setOptions((prev) => ({ ...prev, skip_notifications: checked })) + } + /> +
+
+
+ + +

User Import Settings

+
+
+
+ +

+ If email already exists, update the user instead of skipping +

+
+ + setOptions((prev) => ({ ...prev, update_existing: checked })) + } + /> +
+
+
+ +

+ Continue importing even if some rows have errors +

+
+ + setOptions((prev) => ({ ...prev, skip_errors: checked })) + } + /> +
+
+
+ + +

Data Import Settings

+
+ {uploadSummary?.files_uploaded?.subscriptions && ( +
+
+ +

+ Create subscription records from subscriptions.csv +

+
+ + setOptions((prev) => ({ ...prev, import_subscriptions: checked })) + } + /> +
+ )} + {uploadSummary?.files_uploaded?.donations && ( +
+
+ +

+ Create donation records from donations.csv +

+
+ + setOptions((prev) => ({ ...prev, import_donations: checked })) + } + /> +
+ )} + {uploadSummary?.files_uploaded?.payments && ( +
+
+ +

+ Create payment records from payments.csv +

+
+ + setOptions((prev) => ({ ...prev, import_payments: checked })) + } + /> +
+ )} + {uploadSummary?.files_uploaded?.registration_data && ( +
+
+ +

+ Save custom registration data from registration_data.csv +

+
+ + setOptions((prev) => ({ ...prev, import_registration_data: checked })) + } + /> +
+ )} +
+
+ +
+

After Import

+

+ All imported users will have force_password_change enabled. + You can use the Bulk Password Reset feature to send login instructions after import. +

+
+ +
+ + +
+
+ ); + + // Step 5: Results + const renderResultsStep = () => { + if (!importResults) return null; + + const { results, errors, error_count } = importResults; + + return ( +
+
+ +

Import Complete

+
+ +
+ +
+ {results.users.imported} +
+
Users Imported
+
+ +
+ {results.users.updated} +
+
Users Updated
+
+ +
+ {results.subscriptions.imported} +
+
Subscriptions
+
+ +
+ {results.donations.imported} +
+
Donations
+
+
+ + {results.users.failed > 0 && ( +
+

+ + {results.users.failed} users failed to import +

+
+ )} + + {error_count > 0 && ( + + + + + {error_count} Errors + + +
+ {errors.map((error, idx) => ( +
+ {error.type && {error.type}} + Row {error.row}:{' '} + {error.error} +
+ ))} +
+
+
+
+ )} + +
+ +
+
+ ); + }; + + // 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 ( + + + + + + Template Import Wizard + + + Import users and related data using standardized CSV templates + + + + {renderStepIndicator()} + {renderStepContent()} + + + ); +}; + +export default TemplateImportWizard; diff --git a/src/pages/Login.js b/src/pages/Login.js index efd100b..73ac36e 100644 --- a/src/pages/Login.js +++ b/src/pages/Login.js @@ -16,6 +16,7 @@ const Login = () => { const location = useLocation(); const [searchParams] = useSearchParams(); const { login } = useAuth(); + const basename = process.env.REACT_APP_BASENAME || ''; const [loading, setLoading] = useState(false); const [formData, setFormData] = useState({ email: '', @@ -31,20 +32,20 @@ const Login = () => { toast.info('Your session has expired. Please log in again.', { duration: 5000, }); - // Clean up URL - window.history.replaceState({}, '', '/login'); + // Clean up URL (respect basename for subpath deployments) + window.history.replaceState({}, '', `${basename}/login`); } else if (sessionParam === 'idle') { toast.info('You were logged out due to inactivity. Please log in again.', { duration: 5000, }); - // Clean up URL - window.history.replaceState({}, '', '/login'); + // Clean up URL (respect basename for subpath deployments) + window.history.replaceState({}, '', `${basename}/login`); } else if (stateMessage) { toast.info(stateMessage, { duration: 5000, }); } - }, [searchParams, location.state]); + }, [searchParams, location.state, basename]); const handleInputChange = (e) => { const { name, value } = e.target; diff --git a/src/pages/admin/AdminMembers.js b/src/pages/admin/AdminMembers.js index 71455cf..f9e4905 100644 --- a/src/pages/admin/AdminMembers.js +++ b/src/pages/admin/AdminMembers.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import api from '../../utils/api'; @@ -6,19 +6,24 @@ import { Card } from '../../components/ui/card'; import { Button } from '../../components/ui/button'; import { Input } from '../../components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; +import { Checkbox } from '../../components/ui/checkbox'; +import { Badge } from '../../components/ui/badge'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuSeparator, } from '../../components/ui/dropdown-menu'; import { toast } from 'sonner'; -import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus } from 'lucide-react'; +import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus, KeyRound, Loader2, X } from 'lucide-react'; import PaymentActivationDialog from '../../components/PaymentActivationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog'; import CreateMemberDialog from '../../components/CreateMemberDialog'; import InviteMemberDialog from '../../components/InviteMemberDialog'; import WordPressImportWizard from '../../components/WordPressImportWizard'; +import ComprehensiveImportWizard from '../../components/ComprehensiveImportWizard'; +import TemplateImportWizard from '../../components/TemplateImportWizard'; import StatusBadge from '../../components/StatusBadge'; import { StatCard } from '@/components/StatCard'; import { useMembers } from '../../hooks/use-users'; @@ -45,8 +50,158 @@ const AdminMembers = () => { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); + const [comprehensiveImportOpen, setComprehensiveImportOpen] = useState(false); + const [templateImportOpen, setTemplateImportOpen] = useState(false); const [exporting, setExporting] = useState(false); + // Bulk selection state + const [selectedUsers, setSelectedUsers] = useState(new Set()); + const [bulkActionLoading, setBulkActionLoading] = useState(false); + const [bulkPasswordResetOpen, setBulkPasswordResetOpen] = useState(false); + + // Import job filter state + const [importJobs, setImportJobs] = useState([]); + const [selectedImportJob, setSelectedImportJob] = useState(null); + const [importJobLoading, setImportJobLoading] = useState(false); + + // Fetch import jobs on mount + useEffect(() => { + const fetchImportJobs = async () => { + try { + const response = await api.get('/admin/users/import-jobs'); + // Filter to only show completed/partial jobs that have users + const jobsWithUsers = response.data.filter( + (job) => job.successful_rows > 0 && ['completed', 'partial'].includes(job.status) + ); + setImportJobs(jobsWithUsers); + } catch (error) { + console.error('Failed to fetch import jobs:', error); + } + }; + + if (hasPermission('users.import')) { + fetchImportJobs(); + } + }, [hasPermission]); + + // Select all users from an import job + const selectUsersFromImportJob = useCallback(async (jobId) => { + if (!jobId) { + setSelectedImportJob(null); + return; + } + + setImportJobLoading(true); + setSelectedImportJob(jobId); + + try { + // Get import job details to get the user IDs + const response = await api.get(`/admin/users/import-jobs/${jobId}`); + const importedUserIds = response.data.imported_user_ids || []; + + if (importedUserIds.length === 0) { + toast.info('No users found in this import job'); + setSelectedImportJob(null); + return; + } + + // Filter to only select users that are currently visible in filteredUsers + const visibleImportedUsers = filteredUsers.filter((user) => + importedUserIds.includes(user.id) + ); + + if (visibleImportedUsers.length === 0) { + // If no visible users match, select from all users + const allImportedUsers = users.filter((user) => + importedUserIds.includes(user.id) + ); + + if (allImportedUsers.length > 0) { + setSelectedUsers(new Set(allImportedUsers.map((u) => u.id))); + toast.success( + `Selected ${allImportedUsers.length} users from import job (some may be hidden by current filters)` + ); + } else { + toast.info('No users from this import job found'); + } + } else { + setSelectedUsers(new Set(visibleImportedUsers.map((u) => u.id))); + toast.success(`Selected ${visibleImportedUsers.length} users from import job`); + } + } catch (error) { + const message = error.response?.data?.detail || 'Failed to load import job'; + toast.error(message); + setSelectedImportJob(null); + } finally { + setImportJobLoading(false); + } + }, [filteredUsers, users]); + + // Check if all visible users are selected + const allSelected = useMemo(() => { + if (!filteredUsers || filteredUsers.length === 0) return false; + return filteredUsers.every((user) => selectedUsers.has(user.id)); + }, [filteredUsers, selectedUsers]); + + // Toggle single user selection + const toggleUserSelection = (userId) => { + setSelectedUsers((prev) => { + const newSet = new Set(prev); + if (newSet.has(userId)) { + newSet.delete(userId); + } else { + newSet.add(userId); + } + return newSet; + }); + }; + + // Toggle all visible users + const toggleAllUsers = () => { + if (allSelected) { + setSelectedUsers(new Set()); + } else { + setSelectedUsers(new Set(filteredUsers.map((user) => user.id))); + } + }; + + // Clear selection + const clearSelection = () => { + setSelectedUsers(new Set()); + }; + + // Handle bulk password reset + const handleBulkPasswordReset = async () => { + if (selectedUsers.size === 0) { + toast.error('No users selected'); + return; + } + + setBulkActionLoading(true); + try { + const response = await api.post('/admin/users/bulk-password-reset', { + user_ids: Array.from(selectedUsers), + send_email: true, + }); + + toast.success( + `Password reset emails sent to ${response.data.successful} users` + ); + + if (response.data.failed > 0) { + toast.warning(`${response.data.failed} emails failed to send`); + } + + setBulkPasswordResetOpen(false); + clearSelection(); + } catch (error) { + const message = error.response?.data?.detail || 'Failed to send password reset emails'; + toast.error(message); + } finally { + setBulkActionLoading(false); + } + }; + const handleActivatePayment = (user) => { setSelectedUserForPayment(user); setPaymentDialogOpen(true); @@ -232,13 +387,54 @@ const AdminMembers = () => { )} {hasPermission('users.import') && ( - + + + + + + setTemplateImportOpen(true)} + className="cursor-pointer" + > + +
+ Template Import (Recommended) +

+ Download templates, fill your data, upload +

+
+
+ + setComprehensiveImportOpen(true)} + className="cursor-pointer" + > + +
+ WordPress Import +

+ For WordPress/PMS exports +

+
+
+ setImportDialogOpen(true)} + className="cursor-pointer" + > + +
+ Basic User Import +

+ Simple WordPress users CSV +

+
+
+
+
)} {hasPermission('users.invite') && ( @@ -331,6 +527,102 @@ const AdminMembers = () => {
+ {/* Import Job Quick Select */} + {hasPermission('users.import') && importJobs.length > 0 && ( + +
+
+ + + Quick Select from Import + +
+ +
+ + + {importJobLoading && ( + + )} +
+
+
+ )} + + {/* Bulk Action Bar */} + {selectedUsers.size > 0 && ( + +
+
+ + {selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''} selected + {selectedImportJob && ( + + (from import job) + + )} + + +
+ +
+ {hasPermission('users.reset_password') && ( + + )} +
+
+
+ )} + {/* Members List */} {loading ? (
@@ -338,17 +630,50 @@ const AdminMembers = () => {
) : filteredUsers.length > 0 ? (
+ {/* Select All Row */} + {filteredUsers.length > 0 && hasPermission('users.reset_password') && ( +
+ + +
+ )} + {filteredUsers.map((user) => { const joinedDate = user.created_at; const memberDate = user.member_since; return (
+ {/* Selection Checkbox */} + {hasPermission('users.reset_password') && ( +
+ toggleUserSelection(user.id)} + aria-label={`Select ${user.first_name} ${user.last_name}`} + /> +
+ )} + {/* Avatar */}
{user.first_name?.[0]}{user.last_name?.[0]} @@ -532,6 +857,50 @@ const AdminMembers = () => { onOpenChange={setImportDialogOpen} onSuccess={refetch} /> + + + + + + {/* Bulk Password Reset Confirmation Dialog */} + +

+ You are about to send password reset emails to{' '} + {selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''}. +

+
+

+ What happens: +

+
    +
  • Each user will receive an email with a password reset link
  • +
  • The force_password_change flag will be set
  • +
  • Users must set a new password on their next login
  • +
+
+

+ This action cannot be undone. Continue? +

+
+ } + confirmText={bulkActionLoading ? 'Sending...' : 'Send Emails'} + variant="info" + loading={bulkActionLoading} + /> ); }; diff --git a/src/utils/api.js b/src/utils/api.js index fcf140a..f4ed2d0 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -1,6 +1,7 @@ import axios from 'axios'; const API_URL = process.env.REACT_APP_BACKEND_URL; +const BASENAME = process.env.REACT_APP_BASENAME || ''; export const api = axios.create({ baseURL: `${API_URL}/api`, @@ -48,9 +49,11 @@ api.interceptors.response.use( // Redirect to login with session expired message // Use replace to prevent back button issues + // Include BASENAME to support subpath deployments const currentPath = window.location.pathname; + const loginPath = `${BASENAME}/login?session=expired`; if (!currentPath.includes('/login')) { - window.location.replace('/login?session=expired'); + window.location.replace(loginPath); } }