CSV Import
This commit is contained in:
197
web/index.html
197
web/index.html
@@ -96,10 +96,16 @@
|
||||
<span>Click the "Lists" button next to any member to manage their subscriptions</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="addMemberBtn">
|
||||
<i class="fas fa-plus"></i>
|
||||
Add Member
|
||||
</button>
|
||||
<div class="button-group">
|
||||
<button class="btn btn-primary" id="addMemberBtn">
|
||||
<i class="fas fa-plus"></i>
|
||||
Add Member
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="showBulkImportBtn">
|
||||
<i class="fas fa-upload"></i>
|
||||
Bulk Import CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-table">
|
||||
@@ -273,6 +279,189 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Import Modal -->
|
||||
<div class="modal modal-large" id="bulkImportModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Bulk Import Members from CSV</h3>
|
||||
<button class="modal-close" id="bulkImportModalClose">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Step 1: File Upload -->
|
||||
<div class="import-step" id="importStep1">
|
||||
<h4><i class="fas fa-upload"></i> Step 1: Upload CSV File</h4>
|
||||
<p class="text-muted">Upload a CSV file containing member information. The file must have 'Name' and 'Email' columns.</p>
|
||||
|
||||
<div class="file-upload-area" id="fileUploadArea">
|
||||
<div class="file-upload-content">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<p>Click to select a CSV file or drag and drop</p>
|
||||
<input type="file" id="csvFileInput" accept=".csv" style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-info" id="fileInfo" style="display: none;">
|
||||
<div class="file-details">
|
||||
<i class="fas fa-file-csv"></i>
|
||||
<span id="fileName">file.csv</span>
|
||||
<span id="fileSize" class="text-muted">(0 KB)</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="removeFileBtn">
|
||||
<i class="fas fa-times"></i> Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="csv-format-help">
|
||||
<h5><i class="fas fa-info-circle"></i> CSV Format</h5>
|
||||
<p>Your CSV file should look like this:</p>
|
||||
<div class="code-example">
|
||||
<code>Name,Email,Delivery,Member since<br>
|
||||
Ahmed Ajzan,ahmedajzan@doctors.org.uk,,2025-04-06T18:44:26.819454<br>
|
||||
Alan Bailey,abgower@icloud.com,,2025-04-06T18:44:26.824446</code>
|
||||
</div>
|
||||
<p class="text-muted text-sm">Only the 'Name' and 'Email' columns are required. Additional columns will be ignored.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Preview -->
|
||||
<div class="import-step" id="importStep2" style="display: none;">
|
||||
<h4><i class="fas fa-eye"></i> Step 2: Preview Data</h4>
|
||||
<p class="text-muted">Review the data that will be imported:</p>
|
||||
|
||||
<div class="preview-stats" id="previewStats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="totalRowsCount">0</span>
|
||||
<span class="stat-label">Total Rows</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="validRowsCount">0</span>
|
||||
<span class="stat-label">Valid Rows</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="errorRowsCount">0</span>
|
||||
<span class="stat-label">Errors</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-table-container" id="previewTableContainer">
|
||||
<table class="table table-sm" id="previewTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Row</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="previewTableBody">
|
||||
<!-- Dynamic content -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="error-list" id="errorList" style="display: none;">
|
||||
<div class="error-header">
|
||||
<h5><i class="fas fa-exclamation-triangle"></i> Errors Found</h5>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="toggleErrorsBtn">
|
||||
<span id="errorToggleText">Show All</span>
|
||||
<i class="fas fa-chevron-down" id="errorToggleIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="error-summary" id="errorSummary">
|
||||
<!-- Summary will be shown here -->
|
||||
</div>
|
||||
<div class="error-details" id="errorDetails" style="display: none;">
|
||||
<div class="error-table-container">
|
||||
<table class="table table-sm" id="errorTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Row</th>
|
||||
<th>Issue</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Raw Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="errorTableBody">
|
||||
<!-- Dynamic content -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: List Selection -->
|
||||
<div class="import-step" id="importStep3" style="display: none;">
|
||||
<h4><i class="fas fa-list"></i> Step 3: Select Mailing Lists</h4>
|
||||
<p class="text-muted">Choose which mailing lists these members should be subscribed to:</p>
|
||||
|
||||
<div class="list-selection" id="listSelection">
|
||||
<!-- Dynamic content -->
|
||||
</div>
|
||||
|
||||
<div class="selection-info">
|
||||
<p class="text-muted text-sm">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
If a member already exists, they will be added to the selected lists (existing subscriptions will be preserved).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Import Results -->
|
||||
<div class="import-step" id="importStep4" style="display: none;">
|
||||
<h4><i class="fas fa-check-circle"></i> Step 4: Import Complete</h4>
|
||||
|
||||
<div class="import-results" id="importResults">
|
||||
<div class="result-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="processedCount">0</span>
|
||||
<span class="stat-label">Processed</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="createdCount">0</span>
|
||||
<span class="stat-label">Created</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="updatedCount">0</span>
|
||||
<span class="stat-label">Updated</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="subscriptionsCount">0</span>
|
||||
<span class="stat-label">Subscriptions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-errors" id="resultErrors" style="display: none;">
|
||||
<h5><i class="fas fa-exclamation-triangle"></i> Import Errors</h5>
|
||||
<ul id="resultErrorList">
|
||||
<!-- Dynamic content -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" id="bulkImportBackBtn" style="display: none;">
|
||||
<i class="fas fa-arrow-left"></i> Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" id="bulkImportCancelBtn">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="bulkImportNextBtn" disabled>
|
||||
Next <i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="bulkImportBtn" style="display: none;">
|
||||
<i class="fas fa-upload"></i> Import Members
|
||||
</button>
|
||||
<button type="button" class="btn btn-success" id="bulkImportDoneBtn" style="display: none;">
|
||||
<i class="fas fa-check"></i> Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div class="modal" id="confirmModal">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -405,16 +405,23 @@ body {
|
||||
/* Action buttons in tables */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.action-buttons .btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Ensure actions column is wide enough */
|
||||
.table th:last-child,
|
||||
.table td:last-child {
|
||||
min-width: 220px;
|
||||
width: 220px;
|
||||
min-width: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
/* Subscriptions Grid */
|
||||
@@ -548,6 +555,11 @@ body {
|
||||
padding: var(--space-4) var(--space-6) var(--space-6);
|
||||
}
|
||||
|
||||
/* Forms inside modals need proper padding */
|
||||
.modal form {
|
||||
padding: var(--space-4) var(--space-6) 0;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-5);
|
||||
@@ -684,14 +696,19 @@ body {
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.action-buttons .btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.table th:last-child,
|
||||
.table td:last-child {
|
||||
min-width: auto;
|
||||
width: auto;
|
||||
min-width: 180px;
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,6 +871,339 @@ body {
|
||||
.mt-4 { margin-top: var(--space-4); }
|
||||
.hidden { display: none; }
|
||||
|
||||
/* Bulk Import Styles */
|
||||
.modal-large .modal-content {
|
||||
max-width: 800px;
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
.import-step {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.import-step h4 {
|
||||
color: var(--gray-800);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.file-upload-area {
|
||||
border: 2px dashed var(--gray-300);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--space-8);
|
||||
text-align: center;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.file-upload-area:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(37, 99, 235, 0.05);
|
||||
}
|
||||
|
||||
.file-upload-area.dragover {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(37, 99, 235, 0.05);
|
||||
}
|
||||
|
||||
.file-upload-content i {
|
||||
font-size: var(--font-size-3xl);
|
||||
color: var(--gray-400);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.file-upload-content p {
|
||||
color: var(--gray-600);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4);
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--border-radius);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.file-details i {
|
||||
color: var(--success-color);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.csv-format-help {
|
||||
margin-top: var(--space-6);
|
||||
padding: var(--space-4);
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.csv-format-help h5 {
|
||||
color: var(--gray-800);
|
||||
margin-bottom: var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.code-example {
|
||||
background: var(--gray-800);
|
||||
color: var(--gray-100);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--border-radius);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
margin: var(--space-3) 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--gray-600);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-table-container {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.preview-table-container .table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-table-container .table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--gray-50);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.error-list {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.error-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.error-list h5 {
|
||||
color: var(--danger-color);
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.error-list ul {
|
||||
color: var(--danger-color);
|
||||
margin-left: var(--space-5);
|
||||
}
|
||||
|
||||
.error-summary {
|
||||
color: var(--danger-color);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.error-table-container {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: var(--border-radius);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.error-table-container .table {
|
||||
margin-bottom: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.error-table-container .table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger-color);
|
||||
border-bottom: 2px solid rgba(239, 68, 68, 0.2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.error-table-container .table td {
|
||||
border-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.raw-data-cell {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.raw-data-cell:hover {
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.list-selection {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.list-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.list-checkbox:hover {
|
||||
background: var(--gray-100);
|
||||
}
|
||||
|
||||
.list-checkbox input[type="checkbox"] {
|
||||
margin-right: var(--space-3);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.list-checkbox-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.list-checkbox-name {
|
||||
font-weight: 500;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.list-checkbox-email {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
padding: var(--space-4);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.import-results {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.result-stats .stat-item {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.result-stats .stat-value {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.result-errors {
|
||||
text-align: left;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.result-errors h5 {
|
||||
color: var(--danger-color);
|
||||
margin-bottom: var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.result-errors ul {
|
||||
color: var(--danger-color);
|
||||
margin-left: var(--space-5);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Row status indicators for preview table */
|
||||
.row-status {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.row-status.valid {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.row-status.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
|
||||
@@ -184,6 +184,19 @@ class APIClient {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import members from CSV data
|
||||
*/
|
||||
async bulkImportMembers(csvData, listIds) {
|
||||
return this.request('/bulk-import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
csv_data: csvData,
|
||||
list_ids: listIds
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,6 +45,11 @@ class MailingListApp {
|
||||
this.handleLogin();
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk import button
|
||||
document.getElementById('showBulkImportBtn').addEventListener('click', () => {
|
||||
uiManager.showBulkImportModal();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,6 +50,9 @@ class UIManager {
|
||||
this.showListModal();
|
||||
});
|
||||
|
||||
// Initialize bulk import listeners
|
||||
this.initializeBulkImportListeners();
|
||||
|
||||
document.getElementById('addMemberBtn').addEventListener('click', () => {
|
||||
this.showMemberModal();
|
||||
});
|
||||
@@ -634,6 +637,546 @@ class UIManager {
|
||||
return link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show bulk import modal
|
||||
*/
|
||||
showBulkImportModal() {
|
||||
this.resetBulkImportModal();
|
||||
this.showModal(document.getElementById('bulkImportModal'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset bulk import modal state
|
||||
*/
|
||||
resetBulkImportModal() {
|
||||
// Reset to step 1
|
||||
document.querySelectorAll('.import-step').forEach(step => {
|
||||
step.style.display = 'none';
|
||||
});
|
||||
document.getElementById('importStep1').style.display = 'block';
|
||||
|
||||
// Reset button states
|
||||
document.getElementById('bulkImportBackBtn').style.display = 'none';
|
||||
document.getElementById('bulkImportNextBtn').style.display = 'inline-block';
|
||||
document.getElementById('bulkImportNextBtn').disabled = true;
|
||||
document.getElementById('bulkImportBtn').style.display = 'none';
|
||||
document.getElementById('bulkImportDoneBtn').style.display = 'none';
|
||||
|
||||
// Clear file input
|
||||
document.getElementById('csvFileInput').value = '';
|
||||
document.getElementById('fileInfo').style.display = 'none';
|
||||
|
||||
// Reset parsed data
|
||||
this.csvData = null;
|
||||
this.parsedRows = [];
|
||||
this.currentStep = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize bulk import event listeners
|
||||
*/
|
||||
initializeBulkImportListeners() {
|
||||
const fileUploadArea = document.getElementById('fileUploadArea');
|
||||
const csvFileInput = document.getElementById('csvFileInput');
|
||||
const nextBtn = document.getElementById('bulkImportNextBtn');
|
||||
const backBtn = document.getElementById('bulkImportBackBtn');
|
||||
const importBtn = document.getElementById('bulkImportBtn');
|
||||
const doneBtn = document.getElementById('bulkImportDoneBtn');
|
||||
const removeFileBtn = document.getElementById('removeFileBtn');
|
||||
|
||||
// File upload area click
|
||||
fileUploadArea.addEventListener('click', () => {
|
||||
csvFileInput.click();
|
||||
});
|
||||
|
||||
// File input change
|
||||
csvFileInput.addEventListener('change', (e) => {
|
||||
this.handleFileSelection(e.target.files[0]);
|
||||
});
|
||||
|
||||
// Remove file button
|
||||
removeFileBtn.addEventListener('click', () => {
|
||||
this.removeSelectedFile();
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
fileUploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
fileUploadArea.addEventListener('dragleave', () => {
|
||||
fileUploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
fileUploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadArea.classList.remove('dragover');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
this.handleFileSelection(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Navigation buttons
|
||||
nextBtn.addEventListener('click', () => {
|
||||
this.handleBulkImportNext();
|
||||
});
|
||||
|
||||
backBtn.addEventListener('click', () => {
|
||||
this.handleBulkImportBack();
|
||||
});
|
||||
|
||||
importBtn.addEventListener('click', () => {
|
||||
this.handleBulkImportSubmit();
|
||||
});
|
||||
|
||||
doneBtn.addEventListener('click', () => {
|
||||
this.closeModal(document.getElementById('bulkImportModal'));
|
||||
// Reload data to show imported members
|
||||
if (window.app) {
|
||||
window.app.loadData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file selection
|
||||
*/
|
||||
handleFileSelection(file) {
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||
this.showNotification('Please select a CSV file', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.csvData = e.target.result;
|
||||
this.displayFileInfo(file);
|
||||
this.parseCSVPreview();
|
||||
document.getElementById('bulkImportNextBtn').disabled = false;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display file information
|
||||
*/
|
||||
displayFileInfo(file) {
|
||||
document.getElementById('fileName').textContent = file.name;
|
||||
document.getElementById('fileSize').textContent = `(${this.formatFileSize(file.size)})`;
|
||||
document.getElementById('fileInfo').style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove selected file
|
||||
*/
|
||||
removeSelectedFile() {
|
||||
document.getElementById('csvFileInput').value = '';
|
||||
document.getElementById('fileInfo').style.display = 'none';
|
||||
document.getElementById('bulkImportNextBtn').disabled = true;
|
||||
this.csvData = null;
|
||||
this.parsedRows = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in human readable format
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV for preview
|
||||
*/
|
||||
parseCSVPreview() {
|
||||
if (!this.csvData) return;
|
||||
|
||||
const lines = this.csvData.trim().split('\n');
|
||||
if (lines.length < 2) {
|
||||
this.showNotification('CSV file must contain at least a header and one data row', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse header
|
||||
const headers = this.parseCSVLine(lines[0]);
|
||||
const nameIndex = headers.findIndex(h => h.toLowerCase().includes('name'));
|
||||
const emailIndex = headers.findIndex(h => h.toLowerCase().includes('email'));
|
||||
|
||||
if (nameIndex === -1 || emailIndex === -1) {
|
||||
this.showNotification('CSV must contain Name and Email columns', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.parsedRows = [];
|
||||
this.csvHeaders = headers;
|
||||
let validRows = 0;
|
||||
let errorRows = 0;
|
||||
|
||||
// Parse data rows
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const rawLine = lines[i].trim();
|
||||
if (!rawLine) continue; // Skip empty lines
|
||||
|
||||
const values = this.parseCSVLine(rawLine);
|
||||
const name = (values[nameIndex] || '').trim();
|
||||
const email = (values[emailIndex] || '').trim();
|
||||
|
||||
// Determine error type and message
|
||||
let error = null;
|
||||
let isValid = true;
|
||||
|
||||
if (!email) {
|
||||
error = 'Missing email address';
|
||||
isValid = false;
|
||||
} else if (!this.isValidEmail(email)) {
|
||||
error = 'Invalid email format';
|
||||
isValid = false;
|
||||
}
|
||||
// Note: Missing name is OK - we'll use the email as display name if needed
|
||||
|
||||
const row = {
|
||||
index: i,
|
||||
name,
|
||||
email,
|
||||
valid: isValid,
|
||||
error,
|
||||
rawLine,
|
||||
parsedValues: values,
|
||||
headers: headers
|
||||
};
|
||||
|
||||
this.parsedRows.push(row);
|
||||
if (isValid) validRows++;
|
||||
else errorRows++;
|
||||
}
|
||||
|
||||
// Update preview stats
|
||||
document.getElementById('totalRowsCount').textContent = this.parsedRows.length;
|
||||
document.getElementById('validRowsCount').textContent = validRows;
|
||||
document.getElementById('errorRowsCount').textContent = errorRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CSV line handling quoted values
|
||||
*/
|
||||
parseCSVLine(line) {
|
||||
const result = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple email validation
|
||||
*/
|
||||
isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle next button click
|
||||
*/
|
||||
handleBulkImportNext() {
|
||||
if (this.currentStep === 1) {
|
||||
this.showStep2Preview();
|
||||
} else if (this.currentStep === 2) {
|
||||
this.showStep3ListSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle back button click
|
||||
*/
|
||||
handleBulkImportBack() {
|
||||
if (this.currentStep === 2) {
|
||||
this.showStep1Upload();
|
||||
} else if (this.currentStep === 3) {
|
||||
this.showStep2Preview();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show step 1 (upload)
|
||||
*/
|
||||
showStep1Upload() {
|
||||
document.querySelectorAll('.import-step').forEach(step => {
|
||||
step.style.display = 'none';
|
||||
});
|
||||
document.getElementById('importStep1').style.display = 'block';
|
||||
|
||||
document.getElementById('bulkImportBackBtn').style.display = 'none';
|
||||
document.getElementById('bulkImportNextBtn').style.display = 'inline-block';
|
||||
document.getElementById('bulkImportBtn').style.display = 'none';
|
||||
|
||||
this.currentStep = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show step 2 (preview)
|
||||
*/
|
||||
showStep2Preview() {
|
||||
document.querySelectorAll('.import-step').forEach(step => {
|
||||
step.style.display = 'none';
|
||||
});
|
||||
document.getElementById('importStep2').style.display = 'block';
|
||||
|
||||
document.getElementById('bulkImportBackBtn').style.display = 'inline-block';
|
||||
document.getElementById('bulkImportNextBtn').style.display = 'inline-block';
|
||||
document.getElementById('bulkImportBtn').style.display = 'none';
|
||||
|
||||
this.renderPreviewTable();
|
||||
this.currentStep = 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show step 3 (list selection)
|
||||
*/
|
||||
showStep3ListSelection() {
|
||||
document.querySelectorAll('.import-step').forEach(step => {
|
||||
step.style.display = 'none';
|
||||
});
|
||||
document.getElementById('importStep3').style.display = 'block';
|
||||
|
||||
document.getElementById('bulkImportBackBtn').style.display = 'inline-block';
|
||||
document.getElementById('bulkImportNextBtn').style.display = 'none';
|
||||
document.getElementById('bulkImportBtn').style.display = 'inline-block';
|
||||
|
||||
this.renderListSelection();
|
||||
this.currentStep = 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render preview table
|
||||
*/
|
||||
renderPreviewTable() {
|
||||
const tbody = document.getElementById('previewTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// Show first 10 rows for preview
|
||||
const previewRows = this.parsedRows.slice(0, 10);
|
||||
|
||||
previewRows.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${row.index}</td>
|
||||
<td>${this.escapeHtml(row.name)}</td>
|
||||
<td>${this.escapeHtml(row.email)}</td>
|
||||
<td>
|
||||
<span class="row-status ${row.valid ? 'valid' : 'error'}">
|
||||
${row.valid ? 'Valid' : 'Error'}
|
||||
</span>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
// Show errors if any
|
||||
const errors = this.parsedRows.filter(row => !row.valid);
|
||||
if (errors.length > 0) {
|
||||
document.getElementById('errorList').style.display = 'block';
|
||||
this.renderErrorDisplay(errors);
|
||||
} else {
|
||||
document.getElementById('errorList').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render error display with expandable details
|
||||
*/
|
||||
renderErrorDisplay(errors) {
|
||||
// Update error summary
|
||||
const errorSummary = document.getElementById('errorSummary');
|
||||
const errorTypes = {};
|
||||
|
||||
errors.forEach(error => {
|
||||
const type = error.error;
|
||||
errorTypes[type] = (errorTypes[type] || 0) + 1;
|
||||
});
|
||||
|
||||
const summaryText = Object.entries(errorTypes)
|
||||
.map(([type, count]) => `${count} ${type.toLowerCase()}`)
|
||||
.join(', ');
|
||||
|
||||
errorSummary.innerHTML = `
|
||||
<p><strong>${errors.length}</strong> rows have issues: ${summaryText}</p>
|
||||
<p class="text-muted text-sm">These rows will be skipped during import. Click "Show All" to see details.</p>
|
||||
`;
|
||||
|
||||
// Render error table
|
||||
const errorTableBody = document.getElementById('errorTableBody');
|
||||
errorTableBody.innerHTML = '';
|
||||
|
||||
errors.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td class="font-medium">${row.index + 1}</td>
|
||||
<td class="font-medium">${this.escapeHtml(row.error)}</td>
|
||||
<td>${this.escapeHtml(row.name || '-')}</td>
|
||||
<td>${this.escapeHtml(row.email || '-')}</td>
|
||||
<td class="raw-data-cell" title="${this.escapeHtml(row.rawLine)}">${this.escapeHtml(row.rawLine)}</td>
|
||||
`;
|
||||
errorTableBody.appendChild(tr);
|
||||
});
|
||||
|
||||
// Setup toggle functionality
|
||||
this.setupErrorToggle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup error toggle functionality
|
||||
*/
|
||||
setupErrorToggle() {
|
||||
const toggleBtn = document.getElementById('toggleErrorsBtn');
|
||||
const errorDetails = document.getElementById('errorDetails');
|
||||
const toggleText = document.getElementById('errorToggleText');
|
||||
const toggleIcon = document.getElementById('errorToggleIcon');
|
||||
|
||||
// Remove existing listener to prevent duplicates
|
||||
const newToggleBtn = toggleBtn.cloneNode(true);
|
||||
toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn);
|
||||
|
||||
newToggleBtn.addEventListener('click', () => {
|
||||
const isHidden = errorDetails.style.display === 'none';
|
||||
|
||||
if (isHidden) {
|
||||
errorDetails.style.display = 'block';
|
||||
toggleText.textContent = 'Hide Details';
|
||||
toggleIcon.className = 'fas fa-chevron-up';
|
||||
} else {
|
||||
errorDetails.style.display = 'none';
|
||||
toggleText.textContent = 'Show All';
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render list selection checkboxes
|
||||
*/
|
||||
renderListSelection() {
|
||||
const container = document.getElementById('listSelection');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!window.app || !window.app.lists) {
|
||||
container.innerHTML = '<p class="text-muted">No mailing lists available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
window.app.lists.forEach(list => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'list-checkbox';
|
||||
div.innerHTML = `
|
||||
<input type="checkbox" id="list_${list.list_id}" value="${list.list_id}">
|
||||
<div class="list-checkbox-label">
|
||||
<span class="list-checkbox-name">${this.escapeHtml(list.list_name)}</span>
|
||||
<span class="list-checkbox-email">${this.escapeHtml(list.list_email)}</span>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
// Make the whole div clickable
|
||||
div.addEventListener('click', (e) => {
|
||||
if (e.target.type !== 'checkbox') {
|
||||
const checkbox = div.querySelector('input[type="checkbox"]');
|
||||
checkbox.checked = !checkbox.checked;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle bulk import submission
|
||||
*/
|
||||
async handleBulkImportSubmit() {
|
||||
const selectedLists = Array.from(document.querySelectorAll('#listSelection input[type="checkbox"]:checked'))
|
||||
.map(cb => parseInt(cb.value));
|
||||
|
||||
if (selectedLists.length === 0) {
|
||||
this.showNotification('Please select at least one mailing list', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.setLoading(true);
|
||||
|
||||
const result = await apiClient.bulkImportMembers(this.csvData, selectedLists);
|
||||
|
||||
this.showStep4Results(result);
|
||||
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Bulk import failed');
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show step 4 (results)
|
||||
*/
|
||||
showStep4Results(result) {
|
||||
document.querySelectorAll('.import-step').forEach(step => {
|
||||
step.style.display = 'none';
|
||||
});
|
||||
document.getElementById('importStep4').style.display = 'block';
|
||||
|
||||
document.getElementById('bulkImportBackBtn').style.display = 'none';
|
||||
document.getElementById('bulkImportNextBtn').style.display = 'none';
|
||||
document.getElementById('bulkImportBtn').style.display = 'none';
|
||||
document.getElementById('bulkImportDoneBtn').style.display = 'inline-block';
|
||||
|
||||
// Update result stats
|
||||
document.getElementById('processedCount').textContent = result.processed_rows;
|
||||
document.getElementById('createdCount').textContent = result.created_members;
|
||||
document.getElementById('updatedCount').textContent = result.updated_members;
|
||||
document.getElementById('subscriptionsCount').textContent = result.subscriptions_added;
|
||||
|
||||
// Show errors if any
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
document.getElementById('resultErrors').style.display = 'block';
|
||||
const errorList = document.getElementById('resultErrorList');
|
||||
errorList.innerHTML = '';
|
||||
|
||||
result.errors.forEach(error => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = error;
|
||||
errorList.appendChild(li);
|
||||
});
|
||||
} else {
|
||||
document.getElementById('resultErrors').style.display = 'none';
|
||||
}
|
||||
|
||||
this.currentStep = 4;
|
||||
|
||||
// Show success notification
|
||||
this.showNotification(
|
||||
`Successfully imported ${result.processed_rows} members with ${result.subscriptions_added} subscriptions`,
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user