Files
2026-02-13 10:38:45 -05:00

1016 lines
42 KiB
HTML

{% extends "base.html" %}
{% block title %}Import - QBO Excel Sync{% endblock %}
{% block content %}
<div class="import-page">
<div class="page-header">
<h2>Import Excel Data</h2>
<p>Upload your Excel file and map fields to QuickBooks Online.</p>
</div>
{% if not is_connected %}
<div class="alert alert-warning">
<strong>Not Connected</strong> - Please <a href="{{ url_for('connection') }}">connect to QuickBooks Online</a> first.
</div>
{% endif %}
<div class="import-layout">
<section class="card">
<div class="card-header"><h3>1. Upload Excel File</h3></div>
<div class="card-body">
<div class="file-upload-area" id="upload-area">
<input type="file" id="file-input" accept=".xlsx,.xls" hidden>
<div class="upload-placeholder">
<p>Drag & drop your Excel file here, or <span class="upload-link">browse</span></p>
<small>Supports .xlsx and .xls files</small>
</div>
</div>
<div class="file-info" id="file-info" hidden>
<span class="file-name" id="file-name"></span>
<span class="file-meta" id="file-meta"></span>
<button type="button" class="btn btn-sm" id="remove-file">Remove</button>
</div>
</div>
</section>
<section class="card" id="config-section" hidden>
<div class="card-header"><h3>2. Configure Import</h3></div>
<div class="card-body">
<div class="config-row">
<div class="form-group">
<label for="sheet-select">Sheet</label>
<select id="sheet-select"></select>
</div>
<div class="form-group">
<label for="data-type-select">Import As</label>
<select id="data-type-select">
<option value="check">Check</option>
<option value="invoice">Invoice</option>
<option value="bill">Bill</option>
<option value="customer">Customer</option>
<option value="vendor">Vendor</option>
<option value="account">Chart of Accounts</option>
</select>
</div>
<div class="form-group">
<label for="template-select">Template</label>
<select id="template-select">
<option value="">Default Template</option>
{% for template in templates %}
<option value="{{ template.name }}">{{ template.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</section>
<section class="card tabs-section" id="tabs-section" hidden>
<div class="tabs-header">
<button class="tab-btn active" data-tab="preview">Preview</button>
<button class="tab-btn" data-tab="mapping">Mapping</button>
<button class="tab-btn" data-tab="validation">Validation</button>
</div>
<div class="tab-content active" id="tab-preview">
<div class="tab-toolbar">
<span id="preview-count">0 rows</span>
<button class="btn btn-sm btn-secondary" id="refresh-preview">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Refresh File
</button>
</div>
<div class="table-container">
<table class="data-table" id="preview-table"><thead></thead><tbody></tbody></table>
</div>
</div>
<div class="tab-content" id="tab-mapping">
<div class="tab-toolbar">
<button class="btn btn-sm btn-secondary" id="auto-map">Auto-Map</button>
</div>
<div class="table-container">
<table class="data-table" id="mapping-table">
<thead><tr><th>Excel Column</th><th>QBO Field</th><th>Transform</th><th>Required</th><th>Default</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="tab-content" id="tab-validation">
<div class="validation-summary" id="validation-summary"><p>Click Validate to check data.</p></div>
<div class="table-container">
<table class="data-table" id="validation-table">
<thead><tr><th>Row</th><th>Column</th><th>Field</th><th>Issue</th><th>Value</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
<section class="card" id="actions-section" hidden>
<div class="card-body">
<div class="progress-section" id="import-progress" hidden>
<div class="progress-header">
<span id="progress-text">Processing...</span>
<span id="progress-percentage">0%</span>
</div>
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
<div class="progress-details" id="progress-details"></div>
</div>
<div class="actions-row">
<button class="btn btn-secondary" id="validate-btn" {% if not is_connected %}disabled{% endif %}>Validate</button>
<button class="btn btn-primary" id="import-btn" {% if not is_connected %}disabled{% endif %}>Import to QuickBooks</button>
</div>
</div>
</section>
<section class="card" id="log-section" hidden>
<div class="card-header">
<h3>Import Log</h3>
<div class="log-actions">
<button class="btn btn-sm btn-secondary" onclick="exportLog()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Export Log
</button>
<button class="btn btn-sm btn-secondary" onclick="clearLog()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
Clear Log
</button>
</div>
</div>
<div class="card-body">
<div class="log-output" id="log-output"></div>
</div>
</section>
</div>
</div>
<style>
/* Enhanced Progress Section */
.progress-section {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
#progress-text {
font-weight: 500;
color: #334155;
}
#progress-percentage {
font-weight: 600;
color: #2563eb;
font-size: 14px;
}
.progress-bar {
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #2563eb);
border-radius: 4px;
width: 0%;
transition: width 0.3s ease;
}
.progress-details {
max-height: 200px;
overflow-y: auto;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
background: #1e293b;
color: #e2e8f0;
border-radius: 6px;
padding: 12px;
}
.progress-details:empty {
display: none;
}
.progress-item {
padding: 4px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
display: flex;
align-items: flex-start;
gap: 8px;
}
.progress-item:last-child {
border-bottom: none;
}
.progress-item.success {
color: #4ade80;
}
.progress-item.error {
color: #f87171;
}
.progress-item.processing {
color: #60a5fa;
}
.progress-item .row-num {
color: #94a3b8;
min-width: 60px;
}
.progress-item .status-icon {
min-width: 20px;
}
/* Enhanced Log Styling */
.log-output {
background: #1e293b;
color: #e2e8f0;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
padding: 16px;
border-radius: 8px;
max-height: 400px;
overflow-y: auto;
line-height: 1.6;
}
.log-actions {
display: flex;
gap: 8px;
}
.log-actions .btn {
display: flex;
align-items: center;
gap: 6px;
}
.log-output > div {
padding: 2px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.log-output > div:last-child {
border-bottom: none;
}
.log-timestamp {
color: #64748b;
margin-right: 8px;
}
.log-level {
font-weight: 600;
margin-right: 8px;
}
.log-info {
color: #e2e8f0;
}
.log-info .log-level {
color: #60a5fa;
}
.log-warning {
color: #fef3c7;
}
.log-warning .log-level {
color: #fbbf24;
}
.log-error {
color: #fecaca;
}
.log-error .log-level {
color: #f87171;
}
/* Make log scrollbar visible */
.log-output::-webkit-scrollbar {
width: 8px;
}
.log-output::-webkit-scrollbar-track {
background: #334155;
border-radius: 4px;
}
.log-output::-webkit-scrollbar-thumb {
background: #64748b;
border-radius: 4px;
}
.log-output::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
let currentFile = null;
let excelColumns = [];
let currentMappings = [];
document.addEventListener('DOMContentLoaded', function() {
const uploadArea = document.getElementById('upload-area');
const fileInput = document.getElementById('file-input');
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) handleFileUpload(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) handleFileUpload(e.target.files[0]);
});
document.getElementById('remove-file').addEventListener('click', async () => {
// Clear the file input so the same file can be selected again
document.getElementById('file-input').value = '';
// Clear session on server
try {
await fetch('/api/file/clear', { method: 'POST' });
} catch (e) {
console.log('Could not clear server session:', e);
}
currentFile = null;
excelColumns = [];
currentMappings = [];
document.getElementById('file-info').hidden = true;
document.querySelector('.upload-placeholder').hidden = false;
document.getElementById('config-section').hidden = true;
document.getElementById('tabs-section').hidden = true;
document.getElementById('actions-section').hidden = true;
document.getElementById('log-section').hidden = true;
// Clear preview and log
document.getElementById('preview-table').querySelector('thead').innerHTML = '';
document.getElementById('preview-table').querySelector('tbody').innerHTML = '';
document.getElementById('log-output').innerHTML = '';
showToast('File removed', 'info');
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
});
});
document.getElementById('sheet-select').addEventListener('change', loadPreview);
document.getElementById('data-type-select').addEventListener('change', loadDefaultMappings);
document.getElementById('template-select').addEventListener('change', loadTemplateMappings);
document.getElementById('validate-btn').addEventListener('click', validateData);
document.getElementById('import-btn').addEventListener('click', importData);
document.getElementById('auto-map').addEventListener('click', autoMap);
document.getElementById('refresh-preview').addEventListener('click', refreshFile);
});
async function handleFileUpload(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', { method: 'POST', body: formData });
const result = await response.json();
if (result.success) {
currentFile = result;
excelColumns = result.columns;
document.getElementById('file-info').hidden = false;
document.getElementById('file-name').textContent = result.filename;
document.getElementById('file-meta').textContent = result.sheets.length + ' sheets, ' + result.columns.length + ' columns';
document.querySelector('.upload-placeholder').hidden = true;
const sheetSelect = document.getElementById('sheet-select');
sheetSelect.innerHTML = result.sheets.map(s => '<option value="' + s + '">' + s + '</option>').join('');
document.getElementById('config-section').hidden = false;
document.getElementById('tabs-section').hidden = false;
document.getElementById('actions-section').hidden = false;
document.getElementById('log-section').hidden = false;
loadPreview();
loadDefaultMappings();
showToast('File uploaded successfully', 'success');
} else {
showToast(result.error || 'Upload failed', 'error');
}
} catch (error) {
showToast('Upload error: ' + error.message, 'error');
}
}
async function loadPreview() {
const sheet = document.getElementById('sheet-select').value;
try {
// Load all rows (max_rows=0 means no limit)
const response = await fetch('/api/file/preview?sheet=' + encodeURIComponent(sheet) + '&max_rows=0');
const result = await response.json();
if (result.success) {
excelColumns = result.columns;
renderPreviewTable(result.columns, result.data);
document.getElementById('preview-count').textContent = result.row_count + ' rows total';
}
} catch (error) {
showToast('Error loading preview: ' + error.message, 'error');
}
}
function renderPreviewTable(columns, data) {
const table = document.getElementById('preview-table');
table.querySelector('thead').innerHTML = '<tr>' + columns.map(c => '<th>' + escapeHtml(c) + '</th>').join('') + '</tr>';
table.querySelector('tbody').innerHTML = data.map(row =>
'<tr>' + columns.map(c => '<td>' + escapeHtml(row[c] || '') + '</td>').join('') + '</tr>'
).join('');
}
async function loadDefaultMappings() {
const dataType = document.getElementById('data-type-select').value;
try {
const response = await fetch('/api/templates/default/' + dataType);
const result = await response.json();
if (result.success) {
currentMappings = result.template.mappings;
renderMappingTable();
}
} catch (error) { console.error('Error loading mappings:', error); }
}
async function loadTemplateMappings() {
const templateName = document.getElementById('template-select').value;
if (!templateName) { loadDefaultMappings(); return; }
try {
const response = await fetch('/api/templates');
const result = await response.json();
if (result.success) {
const template = result.templates.find(t => t.name === templateName);
if (template) { currentMappings = template.mappings; renderMappingTable(); }
}
} catch (error) { console.error('Error loading template:', error); }
}
function renderMappingTable() {
const tbody = document.getElementById('mapping-table').querySelector('tbody');
tbody.innerHTML = currentMappings.map((m, i) =>
'<tr data-index="' + i + '">' +
'<td><select class="mapping-column" data-field="excel_column"><option value="">--</option>' +
excelColumns.map(c => '<option value="' + escapeHtml(c) + '"' + (m.excel_column === c ? ' selected' : '') + '>' + escapeHtml(c) + '</option>').join('') + '</select></td>' +
'<td><input type="text" data-field="qbo_field" value="' + escapeHtml(m.qbo_field || '') + '"></td>' +
'<td><select data-field="transform"><option value="">None</option><option value="date"' + (m.transform === 'date' ? ' selected' : '') + '>Date</option><option value="currency"' + (m.transform === 'currency' ? ' selected' : '') + '>Currency</option><option value="number"' + (m.transform === 'number' ? ' selected' : '') + '>Number</option></select></td>' +
'<td><input type="checkbox" data-field="required"' + (m.required ? ' checked' : '') + '></td>' +
'<td><input type="text" data-field="default_value" value="' + escapeHtml(m.default_value || '') + '"></td>' +
'<td><button class="btn btn-sm remove-mapping">X</button></td></tr>'
).join('');
tbody.querySelectorAll('input, select').forEach(el => el.addEventListener('change', updateMappingFromTable));
tbody.querySelectorAll('.remove-mapping').forEach(btn => {
btn.addEventListener('click', (e) => {
const row = e.target.closest('tr');
currentMappings.splice(parseInt(row.dataset.index), 1);
renderMappingTable();
});
});
}
function updateMappingFromTable() {
const tbody = document.getElementById('mapping-table').querySelector('tbody');
currentMappings = [];
tbody.querySelectorAll('tr').forEach(row => {
currentMappings.push({
excel_column: row.querySelector('[data-field="excel_column"]').value,
qbo_field: row.querySelector('[data-field="qbo_field"]').value,
transform: row.querySelector('[data-field="transform"]').value || null,
required: row.querySelector('[data-field="required"]').checked,
default_value: row.querySelector('[data-field="default_value"]').value || null
});
});
}
function autoMap() {
const colsLower = excelColumns.map(c => c.toLowerCase());
currentMappings.forEach(m => {
if (!m.excel_column) {
const qbo = m.qbo_field.toLowerCase();
for (let i = 0; i < colsLower.length; i++) {
if (colsLower[i].includes(qbo) || qbo.includes(colsLower[i])) { m.excel_column = excelColumns[i]; break; }
}
}
});
renderMappingTable();
showToast('Auto-mapping complete', 'success');
}
async function validateData() {
updateMappingFromTable();
showProgress('Validating...');
addLog('info', 'Starting validation...');
try {
const response = await fetch('/api/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sheet: document.getElementById('sheet-select').value, mappings: currentMappings })
});
const result = await response.json();
hideProgress();
if (result.success === false) {
addLog('error', 'Validation failed: ' + (result.error || 'Unknown error'));
if (result.details) {
addLog('error', 'Details: ' + result.details);
}
showToast(result.error || 'Validation failed', 'error');
return;
}
const summary = document.getElementById('validation-summary');
summary.innerHTML = '<div class="validation-stats"><strong>Total:</strong> ' + result.total_rows + ' | <strong>Valid:</strong> ' + result.valid_rows + ' | <strong>Errors:</strong> ' + result.error_count + ' | <strong>Warnings:</strong> ' + (result.warning_count || 0) + '</div>';
const tbody = document.getElementById('validation-table').querySelector('tbody');
tbody.innerHTML = (result.issues || []).map(issue =>
'<tr class="' + issue.severity + '"><td>' + issue.row + '</td><td>' + escapeHtml(issue.column) + '</td><td>' + escapeHtml(issue.field) + '</td><td>' + escapeHtml(issue.message) + '</td><td>' + escapeHtml(issue.value || '') + '</td></tr>'
).join('');
document.querySelector('[data-tab="validation"]').click();
// Log validation summary
addLog('info', 'Validation complete: ' + result.valid_rows + '/' + result.total_rows + ' valid rows');
if (result.error_count > 0) {
addLog('warning', 'Found ' + result.error_count + ' error(s) that will prevent import');
}
if (result.warning_count > 0) {
addLog('warning', 'Found ' + (result.warning_count || 0) + ' warning(s)');
}
// Log first few issues for visibility
(result.issues || []).slice(0, 5).forEach(issue => {
addLog(issue.severity, 'Row ' + issue.row + ', ' + issue.field + ': ' + issue.message);
});
if ((result.issues || []).length > 5) {
addLog('info', '... and ' + (result.issues.length - 5) + ' more issues (see Validation tab)');
}
showToast('Validation complete', result.error_count > 0 ? 'warning' : 'success');
} catch (error) {
hideProgress();
addLog('error', 'Validation request failed: ' + error.message);
addLog('error', 'Stack: ' + (error.stack || 'N/A'));
showToast('Validation error: ' + error.message, 'error');
}
}
async function importData() {
updateMappingFromTable();
if (!confirm('Import data to QuickBooks Online? This cannot be undone.')) return;
showProgress('Preparing import...', 0, 0);
clearProgressDetails();
addLog('info', '════════════════════════════════════════════════════════════');
addLog('info', 'IMPORT STARTED');
addLog('info', '════════════════════════════════════════════════════════════');
addLog('info', 'Data type: ' + document.getElementById('data-type-select').value);
addLog('info', 'Sheet: ' + document.getElementById('sheet-select').value);
addLog('info', 'Mappings: ' + currentMappings.filter(m => m.qbo_field).length + ' fields mapped');
addLog('info', '────────────────────────────────────────────────────────────');
try {
// Start the import job
const startResponse = await fetch('/api/import/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sheet: document.getElementById('sheet-select').value,
data_type: document.getElementById('data-type-select').value,
mappings: currentMappings
})
});
const startResult = await startResponse.json();
if (!startResult.success) {
hideProgress();
addLog('error', 'Import start failed: ' + (startResult.error || 'Unknown error'));
showToast(startResult.error || 'Import failed to start', 'error');
return;
}
const jobId = startResult.job_id;
const totalRecords = startResult.total_records;
addLog('info', 'Import job started: ' + jobId);
addLog('info', 'Total records to process: ' + totalRecords);
addProgressItem('processing', 0, 'Starting import of ' + totalRecords + ' records...');
// Poll for progress
let completed = false;
let lastProcessed = 0;
while (!completed) {
await sleep(500); // Poll every 500ms
const progressResponse = await fetch('/api/import/progress/' + jobId);
const progress = await progressResponse.json();
if (!progress.success) {
hideProgress();
addLog('error', 'Error checking progress: ' + progress.error);
showToast('Error checking import progress', 'error');
return;
}
// Update progress bar
const percentage = totalRecords > 0 ? Math.round((progress.processed / totalRecords) * 100) : 0;
updateProgress('Importing records...', progress.processed, totalRecords);
// Show new record results
if (progress.recent_results && progress.recent_results.length > 0) {
progress.recent_results.forEach(result => {
if (result.row > lastProcessed) {
if (result.status === 'success') {
addProgressItem('success', result.row, 'Created ' + result.data_type + ' ID ' + result.qbo_id);
addLog('info', 'Row ' + result.row + ': SUCCESS - Created ID ' + result.qbo_id);
} else if (result.status === 'failed') {
addProgressItem('error', result.row, result.error);
addLog('error', 'Row ' + result.row + ': FAILED - ' + result.error);
if (result.key_fields) {
addLog('error', 'Row ' + result.row + ': Data - ' + result.key_fields);
}
} else if (result.status === 'duplicate') {
addProgressItem('warning', result.row, 'Skipped (duplicate)');
addLog('warning', 'Row ' + result.row + ': SKIPPED - Duplicate');
} else if (result.status === 'skipped') {
addProgressItem('warning', result.row, 'Skipped - ' + (result.error || 'Unknown reason'));
addLog('warning', 'Row ' + result.row + ': SKIPPED - ' + (result.error || 'Unknown'));
}
lastProcessed = result.row;
}
});
}
// Check if completed
if (progress.status === 'completed' || progress.status === 'failed') {
completed = true;
// Get final result
const finalResponse = await fetch('/api/import/result/' + jobId);
const finalResult = await finalResponse.json();
hideProgress();
if (finalResult.success) {
displayImportResult(finalResult);
} else {
addLog('error', 'Import failed: ' + (finalResult.error || 'Unknown error'));
showToast(finalResult.error || 'Import failed', 'error');
}
}
}
} catch (error) {
hideProgress();
addLog('error', '');
addLog('error', '┌─── REQUEST ERROR ───');
addLog('error', '│ Type: ' + error.name);
addLog('error', '│ Message: ' + error.message);
if (error.stack) {
addLog('error', '│');
addLog('error', '│ Stack:');
error.stack.split('\n').forEach(line => {
if (line.trim()) {
addLog('error', '│ ' + line);
}
});
}
addLog('error', '└─────────────────────');
addLog('error', '');
addLog('error', '════════════════════════════════════════════════════════════');
addLog('error', 'IMPORT FAILED - REQUEST ERROR');
addLog('error', '════════════════════════════════════════════════════════════');
showToast('Import error: ' + error.message, 'error');
}
}
function displayImportResult(result) {
// Log summary
addLog('info', '');
addLog('info', '┌─── IMPORT SUMMARY ───');
addLog('info', '│ Total records: ' + result.total_records);
addLog('info', '│ Successful: ' + result.successful + ' ✓');
if (result.failed > 0) {
addLog('error', '│ Failed: ' + result.failed + ' ✗');
} else {
addLog('info', '│ Failed: ' + result.failed);
}
addLog('info', '│ Duplicates: ' + result.duplicates);
addLog('info', '│ Skipped: ' + result.skipped);
addLog('info', '│ Success rate: ' + (result.success_rate || 0).toFixed(1) + '%');
addLog('info', '│ Duration: ' + (result.duration_seconds || 0).toFixed(2) + ' seconds');
addLog('info', '└──────────────────────');
addLog('info', '');
addLog('info', '════════════════════════════════════════════════════════════');
addLog('info', 'IMPORT COMPLETED');
addLog('info', '════════════════════════════════════════════════════════════');
showToast('Imported ' + result.successful + ' records successfully', result.failed > 0 ? 'warning' : 'success');
let alertMsg = 'Import Complete!\n\n';
alertMsg += '✓ Successful: ' + result.successful + '\n';
alertMsg += '✗ Failed: ' + result.failed + '\n';
alertMsg += '⊘ Duplicates: ' + result.duplicates + '\n';
alertMsg += '○ Skipped: ' + result.skipped + '\n\n';
alertMsg += 'Success Rate: ' + (result.success_rate || 0).toFixed(1) + '%';
if (result.failed > 0) {
alertMsg += '\n\n⚠ Check the Import Log for failed record details.';
}
alert(alertMsg);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function showProgress(text, current, total) {
document.getElementById('import-progress').hidden = false;
document.getElementById('progress-text').textContent = text;
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
document.getElementById('progress-percentage').textContent = percentage + '%';
document.getElementById('progress-fill').style.width = percentage + '%';
}
function updateProgress(text, current, total) {
document.getElementById('progress-text').textContent = text + ' (' + current + '/' + total + ')';
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
document.getElementById('progress-percentage').textContent = percentage + '%';
document.getElementById('progress-fill').style.width = percentage + '%';
}
function hideProgress() {
document.getElementById('import-progress').hidden = true;
}
function clearProgressDetails() {
document.getElementById('progress-details').innerHTML = '';
}
function addProgressItem(status, row, message) {
const details = document.getElementById('progress-details');
let icon = '⋯';
if (status === 'success') icon = '✓';
else if (status === 'error') icon = '✗';
else if (status === 'warning') icon = '⚠';
else if (status === 'processing') icon = '→';
const item = document.createElement('div');
item.className = 'progress-item ' + status;
item.innerHTML = '<span class="status-icon">' + icon + '</span>' +
'<span class="row-num">Row ' + row + ':</span>' +
'<span class="message">' + escapeHtml(message) + '</span>';
details.appendChild(item);
details.scrollTop = details.scrollHeight;
}
function addLog(level, msg) {
const log = document.getElementById('log-output');
const now = new Date();
const timestamp = now.toLocaleTimeString('en-US', { hour12: false }) + '.' + String(now.getMilliseconds()).padStart(3, '0');
const levelClass = level === 'error' ? 'log-error' : (level === 'warning' ? 'log-warning' : 'log-info');
const levelLabel = level.toUpperCase().padEnd(7);
log.innerHTML += '<div class="' + levelClass + '"><span class="log-timestamp">' + timestamp + '</span> <span class="log-level">[' + levelLabel + ']</span> ' + escapeHtml(msg) + '</div>';
log.scrollTop = log.scrollHeight;
}
function clearLog() {
document.getElementById('log-output').innerHTML = '';
}
function exportLog() {
const logOutput = document.getElementById('log-output');
if (!logOutput || logOutput.innerHTML.trim() === '') {
showToast('No log content to export', 'warning');
return;
}
// Extract text content from log entries
const logEntries = logOutput.querySelectorAll('div');
let logText = '';
// Add header
const now = new Date();
logText += '═══════════════════════════════════════════════════════════════\n';
logText += 'QBO EXCEL SYNC - IMPORT LOG\n';
logText += '═══════════════════════════════════════════════════════════════\n';
logText += 'Exported: ' + now.toLocaleString() + '\n';
logText += 'File: ' + (document.getElementById('file-name')?.textContent || 'N/A') + '\n';
logText += 'Data Type: ' + (document.getElementById('data-type-select')?.value || 'N/A') + '\n';
logText += 'Sheet: ' + (document.getElementById('sheet-select')?.value || 'N/A') + '\n';
logText += '═══════════════════════════════════════════════════════════════\n\n';
// Extract log entries
logEntries.forEach(entry => {
const timestamp = entry.querySelector('.log-timestamp')?.textContent || '';
const level = entry.querySelector('.log-level')?.textContent || '';
const message = entry.textContent.replace(timestamp, '').replace(level, '').trim();
logText += timestamp + ' ' + level + ' ' + message + '\n';
});
// Add footer
logText += '\n═══════════════════════════════════════════════════════════════\n';
logText += 'END OF LOG\n';
logText += '═══════════════════════════════════════════════════════════════\n';
// Create and download file
const blob = new Blob([logText], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Generate filename with timestamp
const filename = 'import_log_' + now.toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.txt';
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('Log exported to ' + filename, 'success');
}
async function refreshFile() {
if (!currentFile) {
showToast('No file loaded', 'warning');
return;
}
// Check if we have the original file in the file input
const fileInput = document.getElementById('file-input');
if (fileInput.files && fileInput.files.length > 0) {
// Try to re-read the file from the file input
showToast('Re-reading file...', 'info');
addLog('info', 'Re-reading file from disk...');
const file = fileInput.files[0];
// Check if file is still accessible by trying to read it
try {
// First, try to read a small portion to verify the file is accessible
const testReader = new FileReader();
const testPromise = new Promise((resolve, reject) => {
testReader.onload = () => resolve(true);
testReader.onerror = () => reject(new Error('File is no longer accessible. It may have been moved, deleted, or modified. Please re-select the file.'));
testReader.readAsArrayBuffer(file.slice(0, 100));
});
await testPromise;
// File is accessible, proceed with upload
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', { method: 'POST', body: formData });
if (!response.ok) {
throw new Error('Server returned ' + response.status);
}
const result = await response.json();
if (result.success) {
currentFile = result;
excelColumns = result.columns;
// Update sheet select
const sheetSelect = document.getElementById('sheet-select');
const currentSheet = sheetSelect.value;
sheetSelect.innerHTML = result.sheets.map(s => '<option value="' + s + '"' + (s === currentSheet ? ' selected' : '') + '>' + s + '</option>').join('');
// Update file meta
document.getElementById('file-meta').textContent = result.sheets.length + ' sheets, ' + result.columns.length + ' columns';
// Reload preview
await loadPreview();
// Update mapping table
renderMappingTable();
addLog('info', 'File re-read successfully: ' + result.columns.length + ' columns, ' + result.sheets.length + ' sheets');
showToast('File refreshed from disk', 'success');
} else {
addLog('error', 'Refresh failed: ' + (result.error || 'Unknown error'));
showToast(result.error || 'Refresh failed', 'error');
}
} catch (error) {
// File might have changed on disk - prompt user to re-select
addLog('warning', 'Could not read file: ' + error.message);
addLog('info', 'Please re-select the file to load the updated content.');
const reselect = confirm(
'The file may have been modified or moved.\n\n' +
'Would you like to re-select the file to load the updated content?\n\n' +
'Click OK to select the file again, or Cancel to try server-side refresh.'
);
if (reselect) {
// Trigger file selection dialog
fileInput.value = ''; // Clear to allow same file selection
fileInput.click();
showToast('Please select the updated file', 'info');
} else {
// Try server-side refresh as fallback
await refreshFromServer();
}
}
} else {
// No file in input, use server-side refresh
await refreshFromServer();
}
}
async function refreshFromServer() {
showToast('Refreshing from server...', 'info');
addLog('info', 'Refreshing uploaded file from server...');
try {
const response = await fetch('/api/file/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error('Server returned ' + response.status);
}
const result = await response.json();
if (result.success) {
excelColumns = result.columns;
const sheetSelect = document.getElementById('sheet-select');
const currentSheet = sheetSelect.value;
sheetSelect.innerHTML = result.sheets.map(s => '<option value="' + s + '"' + (s === currentSheet ? ' selected' : '') + '>' + s + '</option>').join('');
document.getElementById('file-meta').textContent = result.sheets.length + ' sheets, ' + result.columns.length + ' columns';
await loadPreview();
renderMappingTable();
addLog('info', 'File refreshed successfully: ' + result.columns.length + ' columns found');
showToast('File refreshed successfully', 'success');
} else {
addLog('error', 'Refresh failed: ' + (result.error || 'Unknown error'));
showToast(result.error || 'Refresh failed', 'error');
}
} catch (error) {
addLog('error', 'Server refresh error: ' + error.message);
showToast('Refresh error: ' + error.message, 'error');
}
}
function escapeHtml(text) { if (!text) return ''; const d = document.createElement('div'); d.textContent = String(text); return d.innerHTML; }
</script>
{% endblock %}