adobe-to-docusign-migrator/web/static/js/migration.js

366 lines
15 KiB
JavaScript

// Migration workflow: options modal → progress → results view
import { api } from './api.js';
import { state, setState } from './state.js';
import { escHtml, formatDateTime, downloadCsv } from './utils.js';
import { navigate } from './router.js';
import { refreshTemplates } from './templates.js';
// ── Helpers ────────────────────────────────────────────────────────────────
function getSettings() {
try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; }
catch { return {}; }
}
// ── Options modal ──────────────────────────────────────────────────────────
export function showOptionsModal(ids) {
if (!ids || !ids.length) return;
const settings = getSettings();
const names = ids.map(id => {
const t = state.templates.find(t => t.adobe_id === id);
return t ? t.name : id;
});
const existing = document.getElementById('migration-modal');
if (existing) existing.remove();
const wrapper = document.createElement('div');
wrapper.id = 'migration-modal';
wrapper.innerHTML = `
<div class="modal-backdrop"></div>
<div class="modal-box">
<div class="modal-header">
<span class="modal-title">Migration Options</span>
<button class="modal-close" id="mm-close">✕</button>
</div>
<div class="modal-body">
<div class="callout info" style="margin-bottom:16px">
<span class="callout-icon">📋</span>
<span>Migrating <strong>${ids.length}</strong> template${ids.length > 1 ? 's' : ''}:
${names.slice(0, 3).map(n => `<em>${escHtml(n)}</em>`).join(', ')}${names.length > 3 ? `… +${names.length - 3} more` : ''}</span>
</div>
<div class="options-panel">
<div class="options-title">Options</div>
<div class="option-row">
<button class="toggle" id="opt-dry-run" role="switch" aria-checked="false"></button>
<div class="option-body">
<div class="option-label">Dry Run</div>
<div class="option-desc">Validate and preview without creating DocuSign templates</div>
</div>
</div>
<div class="option-row">
<button class="toggle" id="opt-overwrite" role="switch"
aria-checked="${settings.defaultOverwrite ? 'true' : 'false'}"
class="toggle ${settings.defaultOverwrite ? 'on' : ''}"></button>
<div class="option-body">
<div class="option-label">Overwrite Existing</div>
<div class="option-desc">Update DocuSign templates that already exist (default: skip)</div>
</div>
</div>
<div class="option-row">
<button class="toggle on" id="opt-include-docs" role="switch" aria-checked="true"></button>
<div class="option-body">
<div class="option-label">Include Documents</div>
<div class="option-desc">Embed PDFs in the DocuSign template payload</div>
</div>
</div>
<div class="option-row" style="align-items:center">
<div class="option-body">
<div class="option-label">Target Folder <span class="form-label-sub">(optional)</span></div>
<input type="text" class="form-input" id="opt-folder"
placeholder="e.g. Migrated Templates" style="margin-top:6px" />
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="mm-cancel">Cancel</button>
<button class="btn btn-primary" id="mm-run">Run Migration →</button>
</div>
</div>
`;
document.body.appendChild(wrapper);
// Wire toggles
wrapper.querySelectorAll('.toggle').forEach(btn => {
if (settings.defaultOverwrite && btn.id === 'opt-overwrite') btn.classList.add('on');
btn.addEventListener('click', () => {
btn.classList.toggle('on');
btn.setAttribute('aria-checked', btn.classList.contains('on'));
});
});
document.getElementById('mm-close').onclick = () => wrapper.remove();
document.getElementById('mm-cancel').onclick = () => wrapper.remove();
document.getElementById('mm-run').onclick = () => _startMigration(ids, wrapper);
}
// ── Start migration ────────────────────────────────────────────────────────
async function _startMigration(ids, wrapper) {
const dryRun = document.getElementById('opt-dry-run')?.classList.contains('on') || false;
const overwrite = document.getElementById('opt-overwrite')?.classList.contains('on') || false;
const includeDocs = !(document.getElementById('opt-include-docs')?.classList.contains('on') === false);
const folder = document.getElementById('opt-folder')?.value.trim() || undefined;
// Replace modal body with progress view
const body = wrapper.querySelector('.modal-body');
const footer = wrapper.querySelector('.modal-footer');
wrapper.querySelector('.modal-title').textContent = dryRun ? 'Dry Run' : 'Migrating…';
footer.innerHTML = ''; // hide footer during migration
const names = ids.map(id => {
const t = state.templates.find(t => t.adobe_id === id);
return { id, name: t ? t.name : id };
});
body.innerHTML = `
<div class="migration-progress">
<div class="progress-wrap">
<div class="progress-label">
<span id="prog-label">Starting…</span>
<span id="prog-count">0 / ${ids.length}</span>
</div>
<div class="progress-bar"><div class="progress-fill" id="prog-bar" style="width:0%"></div></div>
</div>
<div class="progress-template-list" id="prog-list">
${names.map(n => `
<div class="progress-template-row" id="prog-row-${escHtml(n.id)}">
<div class="progress-template-name">${escHtml(n.name)}</div>
<span class="progress-template-status spinner spinner-sm"></span>
</div>`).join('')}
</div>
</div>
`;
try {
const jobData = await api.migrate.batch({
source_template_ids: ids,
target_folder: folder,
options: { dry_run: dryRun, overwrite_if_exists: overwrite, include_documents: includeDocs },
});
const jobId = jobData.job_id;
await pollJob(jobId, (progress) => {
const { completed, total } = progress.progress || { completed: 0, total: ids.length };
const pct = total > 0 ? Math.round(completed / total * 100) : 0;
document.getElementById('prog-bar') && (document.getElementById('prog-bar').style.width = pct + '%');
document.getElementById('prog-count')&& (document.getElementById('prog-count').textContent = `${completed} / ${total}`);
document.getElementById('prog-label')&& (document.getElementById('prog-label').textContent = `Migrating… ${pct}%`);
// Update per-template icons as results come in
(progress.results || []).forEach(r => {
const row = document.getElementById(`prog-row-${r.adobe_template_id}`);
if (!row) return;
const statusEl = row.querySelector('.progress-template-status');
if (!statusEl) return;
statusEl.className = 'progress-template-status';
if (r.status === 'success') statusEl.textContent = r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️';
else if (r.status === 'skipped') statusEl.textContent = '⏭';
else if (r.status === 'blocked') statusEl.textContent = '🚫';
else statusEl.textContent = '❌';
});
});
// Migration done — show "View Results" button
document.getElementById('prog-label') && (document.getElementById('prog-label').textContent = 'Done!');
footer.innerHTML = `
<button class="btn btn-secondary" id="mm-close-done">Close</button>
<button class="btn btn-primary" id="mm-view-results">View Results →</button>
`;
document.getElementById('mm-close-done').onclick = () => wrapper.remove();
document.getElementById('mm-view-results').onclick = () => {
wrapper.remove();
navigate('#/results');
};
// Refresh template list
await refreshTemplates();
state.selectedIds.clear();
} catch (err) {
body.innerHTML += `<div class="callout error" style="margin-top:12px">
<span class="callout-icon">❌</span> Migration failed: ${escHtml(err.message)}
</div>`;
footer.innerHTML = `<button class="btn btn-secondary" id="mm-err-close">Close</button>`;
document.getElementById('mm-err-close').onclick = () => wrapper.remove();
}
}
// ── Poll batch job ─────────────────────────────────────────────────────────
export async function pollJob(jobId, onProgress) {
const POLL_MS = 2000;
const MAX_WAIT = 300000; // 5 minutes
const started = Date.now();
return new Promise((resolve, reject) => {
const tick = async () => {
try {
const data = await api.migrate.batchStatus(jobId);
if (onProgress) onProgress(data);
if (data.status === 'done' || data.status === 'complete') {
setState('lastMigrationResults', data);
resolve(data);
} else if (data.status === 'failed') {
reject(new Error('Migration job failed'));
} else if (Date.now() - started > MAX_WAIT) {
reject(new Error('Migration timed out'));
} else {
setTimeout(tick, POLL_MS);
}
} catch (e) {
reject(e);
}
};
tick();
});
}
// ── Results view ───────────────────────────────────────────────────────────
export function renderResults() {
const outlet = document.getElementById('router-outlet');
const results = state.lastMigrationResults;
if (!results) {
outlet.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📊</div>
<div class="empty-state-title">No migration results yet</div>
<div class="empty-state-sub">Run a migration from the <a href="#/templates" style="color:var(--cobalt)">Templates</a> view to see results here.</div>
</div>`;
return;
}
const templateResults = results.results || [];
const summary = {
created: templateResults.filter(r => r.action === 'created').length,
updated: templateResults.filter(r => r.action === 'updated').length,
skipped: templateResults.filter(r => r.status === 'skipped').length,
blocked: templateResults.filter(r => r.status === 'blocked').length,
errors: templateResults.filter(r => r.status === 'error').length,
dry_run: templateResults.filter(r => r.status === 'dry_run').length,
};
const migratedIds = templateResults
.filter(r => r.status === 'success')
.map(r => r.adobe_template_id);
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Migration Results</div>
<div class="page-subtitle">${formatDateTime(results.completed_at || new Date().toISOString())}</div>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" id="btn-export-results">⬇ Export CSV</button>
${migratedIds.length ? `<button class="btn btn-primary btn-sm" id="btn-verify-results">Verify Templates →</button>` : ''}
</div>
</div>
<!-- Summary stat cards -->
<div class="stat-grid" style="grid-template-columns:repeat(${summary.dry_run ? 6 : 5},1fr)">
<div class="stat-card green">
<div class="stat-label">Created</div>
<div class="stat-value">${summary.created}</div>
</div>
<div class="stat-card blue">
<div class="stat-label">Updated</div>
<div class="stat-value">${summary.updated}</div>
</div>
<div class="stat-card gray">
<div class="stat-label">Skipped</div>
<div class="stat-value">${summary.skipped}</div>
</div>
<div class="stat-card red">
<div class="stat-label">Blocked</div>
<div class="stat-value">${summary.blocked}</div>
</div>
<div class="stat-card red">
<div class="stat-label">Errors</div>
<div class="stat-value">${summary.errors}</div>
</div>
${summary.dry_run ? `<div class="stat-card gray"><div class="stat-label">Dry Run</div><div class="stat-value">${summary.dry_run}</div></div>` : ''}
</div>
<!-- Per-template results -->
<div class="card">
<div class="card-header">
<span class="card-title">Per-Template Results</span>
</div>
<div id="results-list" style="padding:8px 16px">
${templateResults.map(r => _resultRow(r)).join('')}
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px">
<a href="#/templates" class="btn btn-secondary btn-sm">← Back to Templates</a>
</div>
`;
// Expand/collapse result rows
document.querySelectorAll('.result-header').forEach(hdr => {
hdr.addEventListener('click', () => {
hdr.parentElement.classList.toggle('open');
});
});
// Export CSV
document.getElementById('btn-export-results')?.addEventListener('click', () => {
downloadCsv('migration-results.csv', templateResults.map(r => ({
name: r.adobe_template_name || r.adobe_template_id,
adobe_id: r.adobe_template_id,
docusign_id: r.docusign_template_id || '',
status: r.status,
action: r.action || '',
warnings: (r.warnings || []).join('; '),
})));
});
// Verify button
document.getElementById('btn-verify-results')?.addEventListener('click', () => {
import('./verification.js').then(m => {
setState('verifyIds', migratedIds);
navigate('#/verify');
});
});
}
function _resultRow(r) {
const icon = r.status === 'success'
? (r.action === 'created' ? '✅' : r.action === 'dry_run' ? '🔍' : '✏️')
: (r.status === 'skipped' ? '⏭' : r.status === 'blocked' ? '🚫' : '❌');
const statusBadge = r.status === 'success'
? `<span class="badge badge-green">${r.action || 'success'}</span>`
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : 'red'}">${r.status}</span>`;
const warnings = r.warnings || [];
return `
<div class="result-row">
<div class="result-header">
<span class="result-icon">${icon}</span>
<span class="result-name">${escHtml(r.adobe_template_name || r.adobe_template_id)}</span>
${statusBadge}
${r.docusign_template_id ? `<span class="ds-pill" title="${escHtml(r.docusign_template_id)}">DS: ${escHtml(r.docusign_template_id.slice(0,8))}…</span>` : ''}
${warnings.length ? `<span class="result-meta">⚠ ${warnings.length} warning${warnings.length > 1 ? 's' : ''}</span>` : ''}
</div>
${warnings.length || r.error ? `
<div class="result-body">
${warnings.map(w => `<div class="result-warn-item"><span class="ri">⚠</span>${escHtml(w)}</div>`).join('')}
${r.error ? `<div class="result-warn-item" style="color:var(--error)"><span class="ri">❌</span>${escHtml(r.error)}</div>` : ''}
</div>` : ''}
</div>
`;
}