@visns-studio/visns-components 5.15.20 → 5.15.22

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.
package/README.md CHANGED
@@ -69,7 +69,7 @@ VISNS Components is a React-based UI component library that provides a set of re
69
69
 
70
70
  ### Utility Components
71
71
 
72
- - **Fetch** - Axios-based data fetching with error handling
72
+ - **Fetch** - Advanced data fetching with intelligent caching, error handling, and TanStack Query integration
73
73
  - **Download** - File download functionality
74
74
  - **Loader** - Loading indicators
75
75
  - **Notification** - Toast notifications
@@ -2489,18 +2489,20 @@ const BulkCardProcessor = () => {
2489
2489
 
2490
2490
  ### Utility Components
2491
2491
 
2492
- #### CustomFetch
2492
+ #### CustomFetch (Fetch.jsx)
2493
2493
 
2494
- Data fetching utility with error handling and payload size validation.
2494
+ Advanced data fetching utility with intelligent caching, error handling, and payload validation powered by TanStack Query (v5.15.21+).
2495
2495
 
2496
2496
  ```jsx
2497
- // Using async/await
2497
+ import CustomFetch from '@visns-studio/visns-components';
2498
+
2499
+ // Basic usage - async/await
2498
2500
  const result = await CustomFetch('/api/data', 'GET', { param1: 'value1' });
2499
2501
 
2500
2502
  // Using callbacks
2501
2503
  CustomFetch(
2502
2504
  '/api/data',
2503
- 'GET',
2505
+ 'POST',
2504
2506
  { param1: 'value1' },
2505
2507
  (result) => {
2506
2508
  // Success callback
@@ -2511,16 +2513,71 @@ CustomFetch(
2511
2513
  console.error(error);
2512
2514
  }
2513
2515
  );
2516
+
2517
+ // Manual cache management
2518
+ CustomFetch.invalidateCache('/api/data'); // Invalidate specific pattern
2519
+ CustomFetch.clearCache(); // Clear all cache
2514
2520
  ```
2515
2521
 
2516
- The CustomFetch component includes these features:
2522
+ **Core Features:**
2517
2523
 
2518
- - **Payload Size Validation**: Automatically checks if the request payload exceeds the maximum allowed size (default: 4.5MB, configurable via `VITE_MAX_PAYLOAD_SIZE_MB` environment variable)
2519
- - **Toast Notifications**: Displays error messages using toast notifications
2524
+ - **Intelligent Caching**: Selective caching with TanStack Query for optimal performance
2525
+ - **Payload Size Validation**: Automatically validates request size (default: 4.5MB, configurable via `VITE_MAX_PAYLOAD_SIZE_MB`)
2526
+ - **Toast Notifications**: Error messages displayed using react-toastify
2520
2527
  - **Promise Tracking**: Integrates with react-promise-tracker for loading indicators
2521
2528
  - **HTML Error Parsing**: Properly renders HTML content in error messages
2522
- - **CSRF Protection**: Redirects to login page on CSRF token mismatch
2523
- - **Authentication Handling**: Silently handles unauthenticated errors without showing toast notifications
2529
+ - **CSRF Protection**: Automatic token handling and redirect on mismatch
2530
+ - **Authentication Handling**: Silent handling of unauthenticated errors
2531
+
2532
+ **Caching Strategy (v5.15.21+):**
2533
+
2534
+ The Fetch component implements an intelligent caching strategy designed to balance performance with data freshness:
2535
+
2536
+ - **GET Requests**: NOT cached by default to ensure data freshness
2537
+ - **Selective POST Caching**: Only specific safe endpoints are cached:
2538
+ - `/dropdown` - Reference data (10 min stale time) - Industries, facilities, user lists
2539
+ - `/table` - Table queries (1 min stale time) - Grid data with stable results
2540
+ - `/availabilities` - Report availability (2 min stale time) - Report metadata
2541
+ - `/reports/` - Report data (2 min stale time) - Generated report results
2542
+ - `/dashboard/` - Dashboard metrics (5 min stale time) - Aggregated metrics
2543
+ - Wharf usage endpoints (30 sec stale time) - Real-time usage data
2544
+
2545
+ - **Auto-invalidation**: Mutations (POST/PUT/PATCH/DELETE) automatically invalidate related caches:
2546
+ - Lead mutations → invalidate lead tables and dashboards
2547
+ - Client mutations → invalidate client dropdowns and tables
2548
+ - Agreement mutations → invalidate agreement tables and reports
2549
+ - Facility mutations → invalidate availability and report caches
2550
+ - Any data mutation → invalidate related report caches
2551
+
2552
+ - **Cache Configuration**:
2553
+ - **Cache Time**: 10 minutes - data stays in memory
2554
+ - **Stale Time**: Varies by endpoint type (30 seconds to 10 minutes)
2555
+ - **Retry Policy**: Conservative 1 retry on failure
2556
+ - **Window Focus**: No automatic refetch on focus
2557
+ - **Network Reconnect**: Automatic refetch on reconnection
2558
+
2559
+ **Advanced Cache Control:**
2560
+
2561
+ ```jsx
2562
+ // Invalidate specific cache patterns
2563
+ CustomFetch.invalidateCache('/clients/dropdown'); // String pattern
2564
+ CustomFetch.invalidateCache(['clients', 'table']); // Array key
2565
+
2566
+ // Clear entire cache
2567
+ CustomFetch.clearCache();
2568
+
2569
+ // Access QueryClient for advanced operations
2570
+ const queryClient = CustomFetch.getQueryClient();
2571
+ queryClient.invalidateQueries({ queryKey: ['specific', 'key'] });
2572
+ ```
2573
+
2574
+ **Implementation Details:**
2575
+
2576
+ - **Singleton QueryClient**: Single instance shared across entire application
2577
+ - **Cache Key Generation**: Consistent keys based on URL, method, and parameters
2578
+ - **Smart Invalidation**: Related caches automatically cleared on mutations
2579
+ - **Error Preservation**: Original error handling callbacks maintained
2580
+ - **Backward Compatibility**: All existing CustomFetch usage continues to work unchanged
2524
2581
 
2525
2582
  #### CameraPlacement
2526
2583
  Interactive camera placement and visualization tool for security system design. Features a comprehensive Verkada camera database, FOV triangle visualization, and professional export capabilities with enhanced UI consistency.
package/package.json CHANGED
@@ -94,7 +94,7 @@
94
94
  "react-dom": "^17.0.0 || ^18.0.0"
95
95
  },
96
96
  "name": "@visns-studio/visns-components",
97
- "version": "5.15.20",
97
+ "version": "5.15.22",
98
98
  "description": "Various packages to assist in the development of our Custom Applications.",
99
99
  "main": "src/index.js",
100
100
  "files": [
@@ -55,8 +55,8 @@ const generateCacheKey = (url, method, formData) => {
55
55
  const shouldCache = (method, url) => {
56
56
  const normalizedMethod = method.toUpperCase();
57
57
 
58
- // Always cache GET requests
59
- if (normalizedMethod === 'GET') return true;
58
+ // Don't cache GET requests
59
+ if (normalizedMethod === 'GET') return false;
60
60
 
61
61
  // Cache specific safe POST endpoints that return reference data
62
62
  const cacheableEndpoints = [
@@ -75,7 +75,7 @@ const shouldCache = (method, url) => {
75
75
 
76
76
  // Dynamic stale time based on endpoint type
77
77
  const getStaleTime = (url) => {
78
- if (url.includes('/dropdown')) return 10 * 60 * 1000; // 10 minutes for dropdown data
78
+ if (url.includes('/dropdown')) return 1 * 60 * 1000; // 1 minute for dropdown data
79
79
  if (url.includes('/dashboard/')) return 5 * 60 * 1000; // 5 minutes for dashboard data
80
80
  if (url.includes('/availabilities') || url.includes('/reports/')) return 2 * 60 * 1000; // 2 minutes for reports
81
81
  if (url.includes('/table')) return 1 * 60 * 1000; // 1 minute for table data
@@ -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;
@@ -0,0 +1,622 @@
1
+ .importWizardModal {
2
+ .modalContent {
3
+ display: flex;
4
+ flex-direction: column;
5
+ height: 80vh;
6
+ max-height: 800px;
7
+ }
8
+ }
9
+
10
+ .stepIndicator {
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ padding: 2rem 0;
15
+ border-bottom: 1px solid #e5e7eb;
16
+ margin-bottom: 2rem;
17
+
18
+ .stepContainer {
19
+ display: flex;
20
+ align-items: center;
21
+ position: relative;
22
+
23
+ &:not(:last-child) {
24
+ margin-right: 2rem;
25
+ }
26
+ }
27
+
28
+ .step {
29
+ width: 48px;
30
+ height: 48px;
31
+ border-radius: 50%;
32
+ border: 2px solid #d1d5db;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ background: white;
37
+ color: #6b7280;
38
+ transition: all 0.3s ease;
39
+
40
+ &.active {
41
+ border-color: #3b82f6;
42
+ background: #3b82f6;
43
+ color: white;
44
+ }
45
+ }
46
+
47
+ .stepInfo {
48
+ margin-left: 1rem;
49
+
50
+ .stepTitle {
51
+ font-weight: 600;
52
+ font-size: 0.95rem;
53
+ color: #111827;
54
+ }
55
+
56
+ .stepDescription {
57
+ font-size: 0.85rem;
58
+ color: #6b7280;
59
+ }
60
+ }
61
+
62
+ .stepConnector {
63
+ width: 40px;
64
+ height: 2px;
65
+ background: #d1d5db;
66
+ margin: 0 1rem;
67
+ transition: background-color 0.3s ease;
68
+
69
+ &.completed {
70
+ background: #3b82f6;
71
+ }
72
+ }
73
+ }
74
+
75
+ .stepContent {
76
+ flex: 1;
77
+ overflow-y: auto;
78
+ padding: 0 1rem;
79
+ }
80
+
81
+ // Upload Step
82
+ .uploadStep {
83
+ .dropzone {
84
+ border: 2px dashed #d1d5db;
85
+ border-radius: 12px;
86
+ padding: 3rem 2rem;
87
+ text-align: center;
88
+ cursor: pointer;
89
+ transition: all 0.3s ease;
90
+ background: #fafafa;
91
+
92
+ &:hover, &.active {
93
+ border-color: #3b82f6;
94
+ background: #f0f8ff;
95
+ }
96
+
97
+ .uploadIcon {
98
+ color: #6b7280;
99
+ margin-bottom: 1rem;
100
+ }
101
+
102
+ h3 {
103
+ margin: 0 0 0.5rem 0;
104
+ color: #111827;
105
+ font-size: 1.25rem;
106
+ }
107
+
108
+ p {
109
+ margin: 0;
110
+ color: #6b7280;
111
+ font-size: 0.95rem;
112
+ }
113
+
114
+ .fileInfo {
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ gap: 0.5rem;
119
+ margin-top: 1rem;
120
+ padding: 0.75rem 1rem;
121
+ background: #e0f2fe;
122
+ border-radius: 8px;
123
+ color: #0369a1;
124
+ }
125
+ }
126
+
127
+ .templateSection {
128
+ margin-top: 2rem;
129
+ padding: 1.5rem;
130
+ background: #f9fafb;
131
+ border-radius: 8px;
132
+ text-align: center;
133
+
134
+ h4 {
135
+ margin: 0 0 0.5rem 0;
136
+ color: #111827;
137
+ }
138
+
139
+ p {
140
+ margin: 0 0 1rem 0;
141
+ color: #6b7280;
142
+ font-size: 0.9rem;
143
+ }
144
+
145
+ .templateButton {
146
+ display: inline-flex;
147
+ align-items: center;
148
+ gap: 0.5rem;
149
+ padding: 0.75rem 1.25rem;
150
+ background: #3b82f6;
151
+ color: white;
152
+ border: none;
153
+ border-radius: 6px;
154
+ cursor: pointer;
155
+ font-size: 0.9rem;
156
+ transition: background-color 0.2s ease;
157
+
158
+ &:hover {
159
+ background: #2563eb;
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ // Mapping Step
166
+ .mappingStep {
167
+ .mappingHeader {
168
+ margin-bottom: 2rem;
169
+
170
+ h3 {
171
+ margin: 0 0 0.5rem 0;
172
+ color: #111827;
173
+ }
174
+
175
+ p {
176
+ margin: 0;
177
+ color: #6b7280;
178
+ font-size: 0.95rem;
179
+ }
180
+ }
181
+
182
+ .mappingContainer {
183
+ display: grid;
184
+ grid-template-columns: 1fr 300px;
185
+ gap: 2rem;
186
+ }
187
+
188
+ .mappingList {
189
+ .mappingRow {
190
+ display: grid;
191
+ grid-template-columns: 1fr auto 1fr;
192
+ gap: 1rem;
193
+ align-items: center;
194
+ padding: 1rem;
195
+ border: 1px solid #e5e7eb;
196
+ border-radius: 8px;
197
+ margin-bottom: 1rem;
198
+ background: white;
199
+
200
+ .fileColumn {
201
+ strong {
202
+ display: block;
203
+ color: #111827;
204
+ margin-bottom: 0.25rem;
205
+ }
206
+
207
+ .sampleValue {
208
+ font-size: 0.8rem;
209
+ color: #6b7280;
210
+ font-style: italic;
211
+ }
212
+ }
213
+
214
+ .arrow {
215
+ color: #6b7280;
216
+ }
217
+
218
+ .fieldColumn {
219
+ select, .select-container {
220
+ width: 100%;
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ .mappingSummary {
227
+ background: #f9fafb;
228
+ border-radius: 8px;
229
+ padding: 1.5rem;
230
+
231
+ h4 {
232
+ margin: 0 0 1rem 0;
233
+ color: #111827;
234
+ }
235
+
236
+ .summaryItem {
237
+ display: flex;
238
+ justify-content: space-between;
239
+ padding: 0.5rem 0;
240
+ border-bottom: 1px solid #e5e7eb;
241
+
242
+ &:last-child {
243
+ border-bottom: none;
244
+ }
245
+ }
246
+
247
+ .missingFields {
248
+ margin-top: 1rem;
249
+ padding: 1rem;
250
+ background: #fef2f2;
251
+ border-radius: 6px;
252
+ color: #dc2626;
253
+
254
+ display: flex;
255
+ align-items: flex-start;
256
+ gap: 0.5rem;
257
+
258
+ ul {
259
+ margin: 0.5rem 0 0 0;
260
+ padding-left: 1rem;
261
+
262
+ li {
263
+ margin-bottom: 0.25rem;
264
+ }
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ // Preview Step
271
+ .previewStep {
272
+ .previewHeader {
273
+ margin-bottom: 2rem;
274
+
275
+ h3 {
276
+ margin: 0 0 0.5rem 0;
277
+ color: #111827;
278
+ }
279
+
280
+ p {
281
+ margin: 0;
282
+ color: #6b7280;
283
+ font-size: 0.95rem;
284
+ }
285
+ }
286
+
287
+ .validationSummary {
288
+ margin-bottom: 2rem;
289
+
290
+ .summaryCards {
291
+ display: grid;
292
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
293
+ gap: 1rem;
294
+ margin-bottom: 1rem;
295
+
296
+ .summaryCard {
297
+ display: flex;
298
+ align-items: center;
299
+ gap: 1rem;
300
+ padding: 1.5rem;
301
+ background: white;
302
+ border-radius: 8px;
303
+ border: 1px solid #e5e7eb;
304
+
305
+ .number {
306
+ font-size: 1.75rem;
307
+ font-weight: bold;
308
+ color: #111827;
309
+ }
310
+
311
+ .label {
312
+ font-size: 0.9rem;
313
+ color: #6b7280;
314
+ }
315
+
316
+ .successIcon {
317
+ color: #059669;
318
+ }
319
+
320
+ .warningIcon {
321
+ color: #d97706;
322
+ }
323
+ }
324
+ }
325
+
326
+ .errorList {
327
+ background: #fef2f2;
328
+ border-radius: 8px;
329
+ padding: 1rem;
330
+
331
+ h4 {
332
+ margin: 0 0 1rem 0;
333
+ color: #dc2626;
334
+ }
335
+
336
+ .errors {
337
+ max-height: 200px;
338
+ overflow-y: auto;
339
+
340
+ .errorItem {
341
+ margin-bottom: 1rem;
342
+
343
+ strong {
344
+ color: #dc2626;
345
+ }
346
+
347
+ ul {
348
+ margin: 0.5rem 0 0 1rem;
349
+
350
+ li {
351
+ color: #991b1b;
352
+ font-size: 0.9rem;
353
+ }
354
+ }
355
+ }
356
+
357
+ .moreErrors {
358
+ color: #6b7280;
359
+ font-style: italic;
360
+ text-align: center;
361
+ padding-top: 1rem;
362
+ border-top: 1px solid #f3f4f6;
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ .previewTable {
369
+ border: 1px solid #e5e7eb;
370
+ border-radius: 8px;
371
+ overflow: hidden;
372
+
373
+ .tableContainer {
374
+ overflow-x: auto;
375
+ max-height: 400px;
376
+
377
+ table {
378
+ width: 100%;
379
+ border-collapse: collapse;
380
+
381
+ th, td {
382
+ padding: 0.75rem;
383
+ text-align: left;
384
+ border-bottom: 1px solid #e5e7eb;
385
+ font-size: 0.9rem;
386
+ }
387
+
388
+ th {
389
+ background: #f9fafb;
390
+ font-weight: 600;
391
+ color: #111827;
392
+ position: sticky;
393
+ top: 0;
394
+ z-index: 10;
395
+ }
396
+
397
+ .validRow {
398
+ &:hover {
399
+ background: #f0fdf4;
400
+ }
401
+ }
402
+
403
+ .invalidRow {
404
+ background: #fef2f2;
405
+
406
+ &:hover {
407
+ background: #fecaca;
408
+ }
409
+ }
410
+
411
+ .successIcon {
412
+ color: #059669;
413
+ }
414
+
415
+ .errorIcon {
416
+ color: #dc2626;
417
+ }
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ // Import Step
424
+ .importStep {
425
+ display: flex;
426
+ align-items: center;
427
+ justify-content: center;
428
+ min-height: 400px;
429
+
430
+ .importing {
431
+ text-align: center;
432
+
433
+ .spinner {
434
+ animation: spin 1s linear infinite;
435
+ color: #3b82f6;
436
+ margin-bottom: 1rem;
437
+ }
438
+
439
+ h3 {
440
+ margin: 0 0 0.5rem 0;
441
+ color: #111827;
442
+ }
443
+
444
+ p {
445
+ margin: 0;
446
+ color: #6b7280;
447
+ }
448
+ }
449
+
450
+ .importResults {
451
+ text-align: center;
452
+ width: 100%;
453
+
454
+ .successIcon {
455
+ color: #059669;
456
+ margin-bottom: 1rem;
457
+ }
458
+
459
+ h3 {
460
+ margin: 0 0 2rem 0;
461
+ color: #111827;
462
+ }
463
+
464
+ .resultsSummary {
465
+ .resultsGrid {
466
+ display: grid;
467
+ grid-template-columns: repeat(3, 1fr);
468
+ gap: 1rem;
469
+ margin-bottom: 2rem;
470
+
471
+ .resultItem {
472
+ padding: 1.5rem;
473
+ background: white;
474
+ border: 1px solid #e5e7eb;
475
+ border-radius: 8px;
476
+
477
+ .resultNumber {
478
+ font-size: 2rem;
479
+ font-weight: bold;
480
+ color: #111827;
481
+ margin-bottom: 0.25rem;
482
+ }
483
+
484
+ .resultLabel {
485
+ color: #6b7280;
486
+ font-size: 0.9rem;
487
+ }
488
+ }
489
+ }
490
+
491
+ .importErrors {
492
+ background: #fef2f2;
493
+ border-radius: 8px;
494
+ padding: 1rem;
495
+ text-align: left;
496
+
497
+ h4 {
498
+ margin: 0 0 1rem 0;
499
+ color: #dc2626;
500
+ }
501
+
502
+ .errorList {
503
+ max-height: 200px;
504
+ overflow-y: auto;
505
+
506
+ .errorItem {
507
+ margin-bottom: 1rem;
508
+
509
+ strong {
510
+ color: #dc2626;
511
+ }
512
+
513
+ ul {
514
+ margin: 0.5rem 0 0 1rem;
515
+
516
+ li {
517
+ color: #991b1b;
518
+ font-size: 0.9rem;
519
+ }
520
+ }
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }
526
+ }
527
+
528
+ // Footer
529
+ .modalFooter {
530
+ display: flex;
531
+ justify-content: space-between;
532
+ align-items: center;
533
+ padding: 1.5rem 0 0 0;
534
+ border-top: 1px solid #e5e7eb;
535
+ margin-top: 2rem;
536
+
537
+ .footerLeft, .footerRight {
538
+ display: flex;
539
+ gap: 1rem;
540
+ }
541
+
542
+ .backButton, .nextButton, .finishButton {
543
+ display: inline-flex;
544
+ align-items: center;
545
+ gap: 0.5rem;
546
+ padding: 0.75rem 1.5rem;
547
+ border-radius: 6px;
548
+ cursor: pointer;
549
+ font-weight: 500;
550
+ transition: all 0.2s ease;
551
+ border: none;
552
+
553
+ &:disabled {
554
+ opacity: 0.6;
555
+ cursor: not-allowed;
556
+ }
557
+
558
+ .spinner {
559
+ animation: spin 1s linear infinite;
560
+ }
561
+ }
562
+
563
+ .backButton {
564
+ background: #f3f4f6;
565
+ color: #374151;
566
+
567
+ &:hover:not(:disabled) {
568
+ background: #e5e7eb;
569
+ }
570
+ }
571
+
572
+ .nextButton, .finishButton {
573
+ background: #3b82f6;
574
+ color: white;
575
+
576
+ &:hover:not(:disabled) {
577
+ background: #2563eb;
578
+ }
579
+ }
580
+ }
581
+
582
+ @keyframes spin {
583
+ from {
584
+ transform: rotate(0deg);
585
+ }
586
+ to {
587
+ transform: rotate(360deg);
588
+ }
589
+ }
590
+
591
+ // Responsive
592
+ @media (max-width: 768px) {
593
+ .stepIndicator {
594
+ flex-direction: column;
595
+ gap: 1rem;
596
+
597
+ .stepContainer {
598
+ margin-right: 0;
599
+ }
600
+
601
+ .stepConnector {
602
+ display: none;
603
+ }
604
+ }
605
+
606
+ .mappingContainer {
607
+ grid-template-columns: 1fr;
608
+ }
609
+
610
+ .mappingRow {
611
+ grid-template-columns: 1fr;
612
+ gap: 0.5rem;
613
+
614
+ .arrow {
615
+ transform: rotate(90deg);
616
+ }
617
+ }
618
+
619
+ .resultsGrid {
620
+ grid-template-columns: 1fr;
621
+ }
622
+ }
package/src/index.js CHANGED
@@ -38,6 +38,7 @@ import AudioPlayer from './components/controls/AudioPlayer';
38
38
  import GalleryModal from './components/modals/GalleryModal';
39
39
  import ImageModal from './components/ImageModal';
40
40
  import DatePickerPortal from './components/utils/DatePickerPortal';
41
+ import ImportWizardModal from './components/ImportWizardModal';
41
42
  export * from './components/columns/ColumnRenderers.jsx';
42
43
 
43
44
  /** Auth Specific Components */
@@ -137,6 +138,7 @@ export {
137
138
  GenericQuote,
138
139
  GenericReport,
139
140
  GenericSort,
141
+ ImportWizardModal,
140
142
  Loader,
141
143
  Login,
142
144
  MergeEntity,