diff --git a/web/static/js/history.js b/web/static/js/history.js
new file mode 100644
index 0000000..daa670a
--- /dev/null
+++ b/web/static/js/history.js
@@ -0,0 +1,221 @@
+// History & Audit view β filterable, exportable migration history
+
+import { api } from './api.js';
+import { escHtml, formatDateTime, shortHash, downloadCsv, debounce } from './utils.js';
+
+let _allRecords = [];
+let _filter = { search: '', status: 'all', from: '', to: '' };
+let _sort = { col: 'timestamp', dir: 'desc' };
+const PAGE_SIZE = 50;
+let _page = 0;
+
+export async function renderHistory() {
+ const outlet = document.getElementById('router-outlet');
+ outlet.innerHTML = `
`;
+
+ try {
+ const data = await api.migrate.history();
+ _allRecords = (data.history || []).reverse(); // newest first
+ } catch (e) {
+ outlet.innerHTML = `βFailed to load history: ${escHtml(e.message)}
`;
+ return;
+ }
+
+ _page = 0;
+ _render();
+}
+
+function _render() {
+ const outlet = document.getElementById('router-outlet');
+ const filtered = _applyFilter(_allRecords);
+ const page = filtered.slice(_page * PAGE_SIZE, (_page + 1) * PAGE_SIZE);
+ const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
+
+ outlet.innerHTML = `
+
+
+
+
+
+ ${filtered.length === 0 ? `
+
+
π
+
${_allRecords.length ? 'No records match your filter' : 'No migration history yet'}
+
${_allRecords.length ? 'Try clearing the search or filters.' : 'Run a migration to see history here.'}
+
+ ` : `
+
+
+
+
+
+ ${_th('timestamp', 'Time')}
+ ${_th('adobe_template_name', 'Template')}
+ ${_th('action', 'Action')}
+ ${_th('status', 'Status')}
+ | DocuSign ID |
+ Checksum |
+
+
+
+ ${page.map(r => _historyRow(r)).join('')}
+
+
+
+
+ ${totalPages > 1 ? `
+ ` : ''}
+
+ `}
+ `;
+
+ _bindEvents(filtered);
+}
+
+function _th(col, label) {
+ const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
+ return `${label} | `;
+}
+
+function _historyRow(r) {
+ const statusBadge = r.status === 'success'
+ ? `${escHtml(r.action || 'success')}`
+ : `${escHtml(r.status || 'β')}`;
+
+ const checksum = r.checksum_sha256 || r.checksum || '';
+
+ return `
+
+ | ${(r.timestamp||'').slice(0,19).replace('T',' ')} |
+
+ ${escHtml(r.adobe_template_name || r.adobe_template_id || 'β')}
+ ${escHtml(r.adobe_template_id || '')}
+ |
+ ${escHtml(r.action || 'β')} |
+ ${statusBadge} |
+ ${r.docusign_template_id ? escHtml(r.docusign_template_id.slice(0,12)) + 'β¦' : 'β'} |
+
+ ${checksum
+ ? `${escHtml(shortHash(checksum))}`
+ : 'β'}
+ |
+
+ ${(r.blockers || r.warnings || r.error) ? `
+
+
+
+ ${(r.blockers||[]).map(b => ` π« ${escHtml(b)} `).join('')}
+ ${(r.warnings||[]).map(w => ` β ${escHtml(w)} `).join('')}
+ ${r.error ? ` β ${escHtml(r.error)} ` : ''}
+
+ |
+
` : ''}
+ `;
+}
+
+function _applyFilter(records) {
+ let list = [...records];
+
+ if (_filter.search) {
+ const q = _filter.search.toLowerCase();
+ list = list.filter(r =>
+ (r.adobe_template_name || '').toLowerCase().includes(q) ||
+ (r.adobe_template_id || '').toLowerCase().includes(q)
+ );
+ }
+ if (_filter.status !== 'all') {
+ list = list.filter(r => r.status === _filter.status);
+ }
+ if (_filter.from) {
+ list = list.filter(r => r.timestamp >= _filter.from);
+ }
+ if (_filter.to) {
+ list = list.filter(r => r.timestamp <= _filter.to + 'T23:59:59');
+ }
+
+ list.sort((a, b) => {
+ const va = a[_sort.col] || '';
+ const vb = b[_sort.col] || '';
+ return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
+ });
+
+ return list;
+}
+
+function _bindEvents(filtered) {
+ document.getElementById('hist-search')?.addEventListener('input', debounce(e => {
+ _filter.search = e.target.value; _page = 0; _render();
+ }, 250));
+
+ document.querySelectorAll('.filter-tab[data-status]').forEach(btn => {
+ btn.addEventListener('click', () => { _filter.status = btn.dataset.status; _page = 0; _render(); });
+ });
+
+ document.getElementById('hist-from')?.addEventListener('change', e => { _filter.from = e.target.value; _page = 0; _render(); });
+ document.getElementById('hist-to')?.addEventListener('change', e => { _filter.to = e.target.value; _page = 0; _render(); });
+
+ document.querySelectorAll('th.sortable').forEach(th => {
+ th.addEventListener('click', () => {
+ const col = th.dataset.col;
+ _sort.dir = _sort.col === col && _sort.dir === 'asc' ? 'desc' : 'asc';
+ _sort.col = col; _page = 0; _render();
+ });
+ });
+
+ document.getElementById('pg-prev')?.addEventListener('click', () => { if (_page > 0) { _page--; _render(); } });
+ document.getElementById('pg-next')?.addEventListener('click', () => { _page++; _render(); });
+
+ // Expand rows
+ document.querySelectorAll('.row-expandable').forEach(row => {
+ row.addEventListener('click', () => {
+ const next = row.nextElementSibling;
+ if (next && next.classList.contains('row-expanded-content')) {
+ const open = next.style.display !== 'none';
+ next.style.display = open ? 'none' : 'table-row';
+ }
+ });
+ });
+
+ document.getElementById('btn-export-history')?.addEventListener('click', () => {
+ downloadCsv('migration-history.csv', filtered.map(r => ({
+ timestamp: r.timestamp || '',
+ template: r.adobe_template_name || r.adobe_template_id || '',
+ adobe_id: r.adobe_template_id || '',
+ docusign_id: r.docusign_template_id || '',
+ action: r.action || '',
+ status: r.status || '',
+ checksum: r.checksum_sha256 || '',
+ warnings: (r.warnings || []).join('; '),
+ })));
+ });
+}