@visns-studio/visns-components 5.15.21 → 5.15.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,627 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import {
3
+ X,
4
+ Upload,
5
+ ArrowRight,
6
+ ArrowLeft,
7
+ CheckCircle,
8
+ AlertTriangle,
9
+ FileText,
10
+ Eye,
11
+ Download,
12
+ RefreshCw,
13
+ Zap
14
+ } from 'lucide-react';
15
+ import { toast } from 'react-toastify';
16
+ import { useDropzone } from 'react-dropzone';
17
+ import StandardModal from './generic/StandardModal';
18
+ import Select from './Select';
19
+ import CustomFetch from './Fetch';
20
+ import styles from './styles/ImportWizardModal.module.scss';
21
+
22
+ const ImportWizardModal = ({
23
+ isOpen,
24
+ onClose,
25
+ modelConfig = [],
26
+ importUrl,
27
+ onComplete,
28
+ title = "Import Data",
29
+ targetModel,
30
+ parentId = null,
31
+ relationKey = null
32
+ }) => {
33
+ const [currentStep, setCurrentStep] = useState(1);
34
+ const [loading, setLoading] = useState(false);
35
+ const [uploadedFile, setUploadedFile] = useState(null);
36
+ const [fileData, setFileData] = useState(null);
37
+ const [columnMapping, setColumnMapping] = useState({});
38
+ const [previewData, setPreviewData] = useState([]);
39
+ const [validationSummary, setValidationSummary] = useState(null);
40
+ const [importResults, setImportResults] = useState(null);
41
+
42
+ const steps = [
43
+ {
44
+ id: 1,
45
+ title: 'Upload File',
46
+ description: 'Upload your CSV or Excel file',
47
+ icon: <Upload size={20} />
48
+ },
49
+ {
50
+ id: 2,
51
+ title: 'Map Columns',
52
+ description: 'Map file columns to data fields',
53
+ icon: <ArrowRight size={20} />
54
+ },
55
+ {
56
+ id: 3,
57
+ title: 'Preview Data',
58
+ description: 'Review mapped data before import',
59
+ icon: <Eye size={20} />
60
+ },
61
+ {
62
+ id: 4,
63
+ title: 'Import',
64
+ description: 'Import data to system',
65
+ icon: <Zap size={20} />
66
+ }
67
+ ];
68
+
69
+ // Reset state when modal opens/closes
70
+ useEffect(() => {
71
+ if (isOpen) {
72
+ resetWizard();
73
+ }
74
+ }, [isOpen]);
75
+
76
+ const resetWizard = () => {
77
+ setCurrentStep(1);
78
+ setUploadedFile(null);
79
+ setFileData(null);
80
+ setColumnMapping({});
81
+ setPreviewData([]);
82
+ setValidationSummary(null);
83
+ setImportResults(null);
84
+ };
85
+
86
+ // File upload dropzone configuration
87
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
88
+ accept: {
89
+ 'text/csv': ['.csv'],
90
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
91
+ 'application/vnd.ms-excel': ['.xls']
92
+ },
93
+ maxFiles: 1,
94
+ maxSize: 10 * 1024 * 1024, // 10MB
95
+ onDrop: handleFileUpload,
96
+ disabled: loading
97
+ });
98
+
99
+ async function handleFileUpload(acceptedFiles) {
100
+ if (acceptedFiles.length === 0) return;
101
+
102
+ const file = acceptedFiles[0];
103
+ setUploadedFile(file);
104
+ setLoading(true);
105
+
106
+ try {
107
+ const formData = new FormData();
108
+ formData.append('file', file);
109
+
110
+ const response = await CustomFetch('/ajax/import/parse-file', {
111
+ method: 'POST',
112
+ body: formData,
113
+ });
114
+
115
+ if (response.success) {
116
+ setFileData(response.data);
117
+
118
+ // Auto-suggest column mappings
119
+ await getSuggestedMappings(response.data.headers);
120
+
121
+ setCurrentStep(2);
122
+ toast.success(`File parsed successfully! Found ${response.data.total_rows} rows.`);
123
+ } else {
124
+ toast.error(response.message || 'Failed to parse file');
125
+ }
126
+ } catch (error) {
127
+ console.error('File upload error:', error);
128
+ toast.error('Error uploading file');
129
+ } finally {
130
+ setLoading(false);
131
+ }
132
+ }
133
+
134
+ async function getSuggestedMappings(headers) {
135
+ try {
136
+ const response = await CustomFetch('/ajax/import/column-suggestions', {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({
140
+ headers: headers,
141
+ model_fields: modelConfig
142
+ })
143
+ });
144
+
145
+ if (response.success) {
146
+ setColumnMapping(response.suggestions);
147
+ }
148
+ } catch (error) {
149
+ console.error('Error getting suggestions:', error);
150
+ }
151
+ }
152
+
153
+ const handleMappingChange = (fileColumn, modelField) => {
154
+ setColumnMapping(prev => ({
155
+ ...prev,
156
+ [fileColumn]: modelField
157
+ }));
158
+ };
159
+
160
+ const getRequiredFields = () => {
161
+ return modelConfig.filter(field => field.required).map(field => field.id);
162
+ };
163
+
164
+ const getMappedFields = () => {
165
+ return Object.values(columnMapping).filter(field => field);
166
+ };
167
+
168
+ const getUnmappedRequiredFields = () => {
169
+ const requiredFields = getRequiredFields();
170
+ const mappedFields = getMappedFields();
171
+ return requiredFields.filter(field => !mappedFields.includes(field));
172
+ };
173
+
174
+ const canProceedToPreview = () => {
175
+ return getUnmappedRequiredFields().length === 0;
176
+ };
177
+
178
+ async function handlePreviewMapping() {
179
+ if (!canProceedToPreview()) {
180
+ toast.error('Please map all required fields before proceeding');
181
+ return;
182
+ }
183
+
184
+ setLoading(true);
185
+
186
+ try {
187
+ const formData = new FormData();
188
+ formData.append('file', uploadedFile);
189
+ formData.append('mapping', JSON.stringify(columnMapping));
190
+ formData.append('model_config', JSON.stringify(modelConfig));
191
+
192
+ const response = await CustomFetch('/ajax/import/preview-mapping', {
193
+ method: 'POST',
194
+ body: formData,
195
+ });
196
+
197
+ if (response.success) {
198
+ setPreviewData(response.preview);
199
+ setValidationSummary(response.validation_summary);
200
+ setCurrentStep(3);
201
+ } else {
202
+ toast.error(response.message || 'Failed to preview data');
203
+ }
204
+ } catch (error) {
205
+ console.error('Preview error:', error);
206
+ toast.error('Error previewing data');
207
+ } finally {
208
+ setLoading(false);
209
+ }
210
+ }
211
+
212
+ async function handleImport() {
213
+ setLoading(true);
214
+
215
+ try {
216
+ const formData = new FormData();
217
+ formData.append('file', uploadedFile);
218
+ formData.append('mapping', JSON.stringify(columnMapping));
219
+ formData.append('model_config', JSON.stringify(modelConfig));
220
+ formData.append('target_model', targetModel);
221
+ if (parentId) formData.append('parent_id', parentId.toString());
222
+ if (relationKey) formData.append('relation_key', relationKey);
223
+
224
+ const response = await CustomFetch('/ajax/import/process', {
225
+ method: 'POST',
226
+ body: formData,
227
+ });
228
+
229
+ if (response.success) {
230
+ setImportResults(response);
231
+ setCurrentStep(4);
232
+ toast.success(`Import completed! ${response.imported} records imported successfully.`);
233
+ } else {
234
+ toast.error(response.message || 'Import failed');
235
+ }
236
+ } catch (error) {
237
+ console.error('Import error:', error);
238
+ toast.error('Error during import');
239
+ } finally {
240
+ setLoading(false);
241
+ }
242
+ }
243
+
244
+ const handleClose = () => {
245
+ if (importResults && importResults.imported > 0 && onComplete) {
246
+ onComplete();
247
+ }
248
+ onClose();
249
+ };
250
+
251
+ const canGoNext = () => {
252
+ switch (currentStep) {
253
+ case 1: return fileData !== null;
254
+ case 2: return canProceedToPreview();
255
+ case 3: return validationSummary && validationSummary.valid_rows > 0;
256
+ default: return false;
257
+ }
258
+ };
259
+
260
+ const handleNext = () => {
261
+ switch (currentStep) {
262
+ case 2:
263
+ handlePreviewMapping();
264
+ break;
265
+ case 3:
266
+ handleImport();
267
+ break;
268
+ default:
269
+ setCurrentStep(prev => prev + 1);
270
+ }
271
+ };
272
+
273
+ const handleBack = () => {
274
+ setCurrentStep(prev => Math.max(1, prev - 1));
275
+ };
276
+
277
+ const renderStepIndicator = () => (
278
+ <div className={styles.stepIndicator}>
279
+ {steps.map((step, index) => (
280
+ <div key={step.id} className={styles.stepContainer}>
281
+ <div className={`${styles.step} ${currentStep >= step.id ? styles.active : ''}`}>
282
+ {step.icon}
283
+ </div>
284
+ <div className={styles.stepInfo}>
285
+ <div className={styles.stepTitle}>{step.title}</div>
286
+ <div className={styles.stepDescription}>{step.description}</div>
287
+ </div>
288
+ {index < steps.length - 1 && (
289
+ <div className={`${styles.stepConnector} ${currentStep > step.id ? styles.completed : ''}`} />
290
+ )}
291
+ </div>
292
+ ))}
293
+ </div>
294
+ );
295
+
296
+ const renderUploadStep = () => (
297
+ <div className={styles.uploadStep}>
298
+ <div {...getRootProps()} className={`${styles.dropzone} ${isDragActive ? styles.active : ''}`}>
299
+ <input {...getInputProps()} />
300
+ <Upload size={48} className={styles.uploadIcon} />
301
+ <h3>Drop your file here, or click to browse</h3>
302
+ <p>Supports CSV, XLS, and XLSX files (max 10MB)</p>
303
+ {uploadedFile && (
304
+ <div className={styles.fileInfo}>
305
+ <FileText size={20} />
306
+ <span>{uploadedFile.name}</span>
307
+ </div>
308
+ )}
309
+ </div>
310
+
311
+ <div className={styles.templateSection}>
312
+ <h4>Need a template?</h4>
313
+ <p>Download a template with the correct column headers:</p>
314
+ <button
315
+ type="button"
316
+ className={styles.templateButton}
317
+ onClick={() => downloadTemplate()}
318
+ >
319
+ <Download size={16} />
320
+ Download Template
321
+ </button>
322
+ </div>
323
+ </div>
324
+ );
325
+
326
+ const downloadTemplate = () => {
327
+ const headers = modelConfig.map(field => field.label);
328
+ const csvContent = headers.join(',') + '\n';
329
+ const blob = new Blob([csvContent], { type: 'text/csv' });
330
+ const url = window.URL.createObjectURL(blob);
331
+ const a = document.createElement('a');
332
+ a.href = url;
333
+ a.download = `${title.toLowerCase().replace(/\s+/g, '_')}_template.csv`;
334
+ a.click();
335
+ window.URL.revokeObjectURL(url);
336
+ };
337
+
338
+ const renderMappingStep = () => (
339
+ <div className={styles.mappingStep}>
340
+ <div className={styles.mappingHeader}>
341
+ <h3>Map File Columns to Data Fields</h3>
342
+ <p>Match columns from your file to the appropriate data fields. Required fields are marked with *</p>
343
+ </div>
344
+
345
+ <div className={styles.mappingContainer}>
346
+ <div className={styles.mappingList}>
347
+ {fileData.headers.map(header => (
348
+ <div key={header} className={styles.mappingRow}>
349
+ <div className={styles.fileColumn}>
350
+ <strong>{header}</strong>
351
+ {fileData.sample_data.length > 0 && (
352
+ <div className={styles.sampleValue}>
353
+ Example: "{fileData.sample_data[0][header] || 'N/A'}"
354
+ </div>
355
+ )}
356
+ </div>
357
+
358
+ <ArrowRight size={20} className={styles.arrow} />
359
+
360
+ <div className={styles.fieldColumn}>
361
+ <Select
362
+ value={columnMapping[header] || ''}
363
+ onChange={(value) => handleMappingChange(header, value)}
364
+ options={[
365
+ { value: '', label: 'Skip Column' },
366
+ ...modelConfig.map(field => ({
367
+ value: field.id,
368
+ label: `${field.label}${field.required ? ' *' : ''}`,
369
+ disabled: getMappedFields().includes(field.id) && columnMapping[header] !== field.id
370
+ }))
371
+ ]}
372
+ placeholder="Select field..."
373
+ />
374
+ </div>
375
+ </div>
376
+ ))}
377
+ </div>
378
+
379
+ <div className={styles.mappingSummary}>
380
+ <h4>Mapping Summary</h4>
381
+ <div className={styles.summaryItem}>
382
+ <span>Total Columns:</span>
383
+ <span>{fileData.headers.length}</span>
384
+ </div>
385
+ <div className={styles.summaryItem}>
386
+ <span>Mapped Columns:</span>
387
+ <span>{getMappedFields().length}</span>
388
+ </div>
389
+ <div className={styles.summaryItem}>
390
+ <span>Required Fields:</span>
391
+ <span>{getRequiredFields().length}</span>
392
+ </div>
393
+
394
+ {getUnmappedRequiredFields().length > 0 && (
395
+ <div className={styles.missingFields}>
396
+ <AlertTriangle size={16} />
397
+ <div>
398
+ <strong>Missing Required Fields:</strong>
399
+ <ul>
400
+ {getUnmappedRequiredFields().map(field => {
401
+ const fieldConfig = modelConfig.find(f => f.id === field);
402
+ return <li key={field}>{fieldConfig?.label || field}</li>;
403
+ })}
404
+ </ul>
405
+ </div>
406
+ </div>
407
+ )}
408
+ </div>
409
+ </div>
410
+ </div>
411
+ );
412
+
413
+ const renderPreviewStep = () => (
414
+ <div className={styles.previewStep}>
415
+ <div className={styles.previewHeader}>
416
+ <h3>Data Preview</h3>
417
+ <p>Review the mapped data before importing. Only the first 10 rows are shown.</p>
418
+ </div>
419
+
420
+ {validationSummary && (
421
+ <div className={styles.validationSummary}>
422
+ <div className={styles.summaryCards}>
423
+ <div className={styles.summaryCard}>
424
+ <CheckCircle size={24} className={styles.successIcon} />
425
+ <div>
426
+ <div className={styles.number}>{validationSummary.valid_rows}</div>
427
+ <div className={styles.label}>Valid Rows</div>
428
+ </div>
429
+ </div>
430
+
431
+ <div className={styles.summaryCard}>
432
+ <AlertTriangle size={24} className={styles.warningIcon} />
433
+ <div>
434
+ <div className={styles.number}>{validationSummary.invalid_rows}</div>
435
+ <div className={styles.label}>Invalid Rows</div>
436
+ </div>
437
+ </div>
438
+ </div>
439
+
440
+ {validationSummary.errors.length > 0 && (
441
+ <div className={styles.errorList}>
442
+ <h4>Validation Errors:</h4>
443
+ <div className={styles.errors}>
444
+ {validationSummary.errors.slice(0, 5).map((error, index) => (
445
+ <div key={index} className={styles.errorItem}>
446
+ <strong>Row {error.row}:</strong>
447
+ <ul>
448
+ {error.errors.map((err, i) => <li key={i}>{err}</li>)}
449
+ </ul>
450
+ </div>
451
+ ))}
452
+ {validationSummary.errors.length > 5 && (
453
+ <div className={styles.moreErrors}>
454
+ ... and {validationSummary.errors.length - 5} more errors
455
+ </div>
456
+ )}
457
+ </div>
458
+ </div>
459
+ )}
460
+ </div>
461
+ )}
462
+
463
+ <div className={styles.previewTable}>
464
+ <div className={styles.tableContainer}>
465
+ <table>
466
+ <thead>
467
+ <tr>
468
+ <th>Status</th>
469
+ {getMappedFields().map(field => {
470
+ const fieldConfig = modelConfig.find(f => f.id === field);
471
+ return <th key={field}>{fieldConfig?.label || field}</th>;
472
+ })}
473
+ </tr>
474
+ </thead>
475
+ <tbody>
476
+ {previewData.map((row, index) => (
477
+ <tr key={index} className={row.valid ? styles.validRow : styles.invalidRow}>
478
+ <td>
479
+ {row.valid ? (
480
+ <CheckCircle size={16} className={styles.successIcon} />
481
+ ) : (
482
+ <AlertTriangle size={16} className={styles.errorIcon} />
483
+ )}
484
+ </td>
485
+ {getMappedFields().map(field => (
486
+ <td key={field}>{row.mapped[field] || '-'}</td>
487
+ ))}
488
+ </tr>
489
+ ))}
490
+ </tbody>
491
+ </table>
492
+ </div>
493
+ </div>
494
+ </div>
495
+ );
496
+
497
+ const renderImportStep = () => (
498
+ <div className={styles.importStep}>
499
+ {loading ? (
500
+ <div className={styles.importing}>
501
+ <RefreshCw size={48} className={styles.spinner} />
502
+ <h3>Importing data...</h3>
503
+ <p>Please wait while we process your data.</p>
504
+ </div>
505
+ ) : (
506
+ <div className={styles.importResults}>
507
+ <CheckCircle size={64} className={styles.successIcon} />
508
+ <h3>Import Completed!</h3>
509
+
510
+ {importResults && (
511
+ <div className={styles.resultsSummary}>
512
+ <div className={styles.resultsGrid}>
513
+ <div className={styles.resultItem}>
514
+ <div className={styles.resultNumber}>{importResults.imported}</div>
515
+ <div className={styles.resultLabel}>Records Imported</div>
516
+ </div>
517
+
518
+ <div className={styles.resultItem}>
519
+ <div className={styles.resultNumber}>{importResults.errors?.length || 0}</div>
520
+ <div className={styles.resultLabel}>Errors</div>
521
+ </div>
522
+
523
+ <div className={styles.resultItem}>
524
+ <div className={styles.resultNumber}>{importResults.skipped}</div>
525
+ <div className={styles.resultLabel}>Skipped</div>
526
+ </div>
527
+ </div>
528
+
529
+ {importResults.errors && importResults.errors.length > 0 && (
530
+ <div className={styles.importErrors}>
531
+ <h4>Import Errors:</h4>
532
+ <div className={styles.errorList}>
533
+ {importResults.errors.slice(0, 5).map((error, index) => (
534
+ <div key={index} className={styles.errorItem}>
535
+ <strong>Row {error.row}:</strong>
536
+ <ul>
537
+ {error.errors.map((err, i) => <li key={i}>{err}</li>)}
538
+ </ul>
539
+ </div>
540
+ ))}
541
+ </div>
542
+ </div>
543
+ )}
544
+ </div>
545
+ )}
546
+ </div>
547
+ )}
548
+ </div>
549
+ );
550
+
551
+ const renderCurrentStep = () => {
552
+ switch (currentStep) {
553
+ case 1: return renderUploadStep();
554
+ case 2: return renderMappingStep();
555
+ case 3: return renderPreviewStep();
556
+ case 4: return renderImportStep();
557
+ default: return null;
558
+ }
559
+ };
560
+
561
+ return (
562
+ <StandardModal
563
+ isOpen={isOpen}
564
+ onClose={handleClose}
565
+ title={title}
566
+ size="xl"
567
+ className={styles.importWizardModal}
568
+ >
569
+ <div className={styles.modalContent}>
570
+ {renderStepIndicator()}
571
+
572
+ <div className={styles.stepContent}>
573
+ {renderCurrentStep()}
574
+ </div>
575
+
576
+ <div className={styles.modalFooter}>
577
+ <div className={styles.footerLeft}>
578
+ {currentStep > 1 && currentStep < 4 && (
579
+ <button
580
+ type="button"
581
+ onClick={handleBack}
582
+ className={styles.backButton}
583
+ disabled={loading}
584
+ >
585
+ <ArrowLeft size={16} />
586
+ Back
587
+ </button>
588
+ )}
589
+ </div>
590
+
591
+ <div className={styles.footerRight}>
592
+ {currentStep < 4 ? (
593
+ <button
594
+ type="button"
595
+ onClick={handleNext}
596
+ className={styles.nextButton}
597
+ disabled={!canGoNext() || loading}
598
+ >
599
+ {loading ? (
600
+ <>
601
+ <RefreshCw size={16} className={styles.spinner} />
602
+ Processing...
603
+ </>
604
+ ) : (
605
+ <>
606
+ {currentStep === 3 ? 'Import' : 'Next'}
607
+ <ArrowRight size={16} />
608
+ </>
609
+ )}
610
+ </button>
611
+ ) : (
612
+ <button
613
+ type="button"
614
+ onClick={handleClose}
615
+ className={styles.finishButton}
616
+ >
617
+ Finish
618
+ </button>
619
+ )}
620
+ </div>
621
+ </div>
622
+ </div>
623
+ </StandardModal>
624
+ );
625
+ };
626
+
627
+ export default ImportWizardModal;