556 lines
22 KiB
JavaScript
556 lines
22 KiB
JavaScript
// Templates view — filterable table with readiness badges + template detail
|
||
|
||
import { api } from './api.js';
|
||
import { state, setState, updateDerivedState } from './state.js';
|
||
import { escHtml, formatDate, formatRelative, debounce, renderFieldIssues, bindFieldIssueToggles } from './utils.js';
|
||
import { navigate } from './router.js';
|
||
import { bindQuickStartCard, quickStartCardMarkup, shouldShowQuickStart } from './help.js';
|
||
|
||
// ── Readiness badge ────────────────────────────────────────────────────────
|
||
|
||
function readiness(t) {
|
||
if (t.blockers && t.blockers.length > 0) {
|
||
return { key: 'blocked', label: 'Blocked', cls: 'badge-blocked' };
|
||
}
|
||
if (hasFieldIssues(t)) {
|
||
return { key: 'field-caveats', label: 'Caveats', cls: 'badge-caveats' };
|
||
}
|
||
if (t.status === 'migrated') {
|
||
return hasWarnings(t)
|
||
? { key: 'migrated-warn', label: 'Migrated', cls: 'badge-migrated' }
|
||
: { key: 'migrated', label: 'Migrated', cls: 'badge-migrated' };
|
||
}
|
||
if (t.status === 'needs_update') {
|
||
return { key: 'needs-update', label: 'Needs Update', cls: 'badge-needs-update' };
|
||
}
|
||
if (hasWarnings(t)) {
|
||
return { key: 'caveats', label: 'Caveats', cls: 'badge-caveats' };
|
||
}
|
||
return { key: 'ready', label: 'Ready', cls: 'badge-ready' };
|
||
}
|
||
|
||
// ── Refresh templates from API ─────────────────────────────────────────────
|
||
|
||
export async function refreshTemplates() {
|
||
if (!state.auth.adobe || !state.auth.docusign) {
|
||
setState('templates', []);
|
||
setState('templatesError', null);
|
||
updateDerivedState();
|
||
return;
|
||
}
|
||
try {
|
||
const data = await api.templates.status();
|
||
setState('templates', data.templates || []);
|
||
setState('templatesError', null);
|
||
updateDerivedState();
|
||
} catch (e) {
|
||
console.warn('refreshTemplates failed:', e.message);
|
||
setState('templates', []);
|
||
setState('templatesError', e.data?.error || e.message || 'Failed to load templates.');
|
||
updateDerivedState();
|
||
}
|
||
}
|
||
|
||
// ── Templates list view ────────────────────────────────────────────────────
|
||
|
||
let _filter = { search: '', status: 'all' };
|
||
let _sort = { col: 'name', dir: 'asc' };
|
||
|
||
export async function renderTemplates() {
|
||
const outlet = document.getElementById('router-outlet');
|
||
|
||
// Fetch if not loaded
|
||
if (!state.templates.length && state.auth.adobe && state.auth.docusign) {
|
||
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
|
||
await refreshTemplates();
|
||
}
|
||
|
||
_render();
|
||
}
|
||
|
||
function _render() {
|
||
const outlet = document.getElementById('router-outlet');
|
||
const templates = _applyFilter(state.templates);
|
||
|
||
const counts = _statusCounts(state.templates);
|
||
const anySelected = state.selectedIds.size > 0;
|
||
|
||
outlet.innerHTML = `
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-title">Templates</div>
|
||
<div class="page-subtitle">${state.templates.length} Adobe Sign templates</div>
|
||
</div>
|
||
<div class="page-actions">
|
||
<button class="btn btn-secondary btn-sm" id="btn-refresh-templates">↻ Refresh</button>
|
||
</div>
|
||
</div>
|
||
|
||
${!state.auth.adobe || !state.auth.docusign ? `
|
||
<div class="callout info">
|
||
<span class="callout-icon">ℹ️</span>
|
||
Connect both Adobe Sign and Docusign in the top bar to load templates.
|
||
</div>
|
||
` : ''}
|
||
|
||
${state.templatesError ? `
|
||
<div class="callout error">
|
||
<span class="callout-icon">❌</span>
|
||
Template loading failed: ${escHtml(state.templatesError)}
|
||
</div>
|
||
` : ''}
|
||
|
||
${shouldShowQuickStart() ? quickStartCardMarkup() : ''}
|
||
|
||
<!-- Filter bar -->
|
||
<div class="filter-bar">
|
||
<input type="search" class="search-input" id="template-search"
|
||
placeholder="Search templates…" value="${escHtml(_filter.search)}" />
|
||
<div class="filter-tabs">
|
||
${_filterTab('all', `All <span class="tab-count">${counts.total}</span>`)}
|
||
${_filterTab('not_migrated', `Not Migrated <span class="tab-count">${counts.not_migrated}</span>`)}
|
||
${_filterTab('migrated', `Migrated <span class="tab-count">${counts.migrated}</span>`)}
|
||
${_filterTab('needs_update', `Needs Update <span class="tab-count">${counts.needs_update}</span>`)}
|
||
</div>
|
||
<div class="filter-tabs">
|
||
${_filterTab2('blocked', `Blocked <span class="tab-count">${counts.blocked}</span>`)}
|
||
${_filterTab2('caveats', `Caveats <span class="tab-count">${counts.caveats}</span>`)}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bulk action toolbar -->
|
||
<div class="bulk-bar ${anySelected ? '' : 'hidden'}" id="bulk-bar">
|
||
<span class="bulk-bar-text">${state.selectedIds.size} template(s) selected</span>
|
||
<button class="btn btn-primary btn-sm" id="btn-migrate-selected">Migrate Selected →</button>
|
||
<button class="btn btn-secondary btn-sm" id="btn-clear-selection">Clear</button>
|
||
</div>
|
||
|
||
<!-- Templates table -->
|
||
<div class="card">
|
||
<div class="table-wrap">
|
||
<table id="templates-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:34px">
|
||
<input type="checkbox" class="cb" id="select-all" title="Select all" />
|
||
</th>
|
||
${_th('name', 'Template Name')}
|
||
${_th('readiness', 'Readiness')}
|
||
${_th('warnings', 'Issues')}
|
||
${_th('adobe_modified','Last Modified')}
|
||
${_th('status', 'DS Status')}
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${templates.length
|
||
? templates.map(t => _templateRow(t)).join('')
|
||
: `<tr><td colspan="7">
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">📄</div>
|
||
<div class="empty-state-title">${state.templates.length ? 'No templates match your filter' : 'No templates found'}</div>
|
||
<div class="empty-state-sub">${state.templates.length ? 'Try clearing the search or filter.' : (state.templatesError ? 'The template load failed. Check the error message above.' : 'Connect Adobe Sign to load templates.')}</div>
|
||
</div>
|
||
</td></tr>`
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
_bindEvents();
|
||
}
|
||
|
||
function _filterTab(key, label) {
|
||
return `<button class="filter-tab ${_filter.status === key ? 'active' : ''}"
|
||
data-filter="${key}">${label}</button>`;
|
||
}
|
||
|
||
function _filterTab2(key, label) {
|
||
// readiness-based filters
|
||
return `<button class="filter-tab ${_filter.status === key ? 'active' : ''}"
|
||
data-filter="${key}">${label}</button>`;
|
||
}
|
||
|
||
function _th(col, label) {
|
||
const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
|
||
return `<th class="sortable ${dir}" data-col="${col}">${label}</th>`;
|
||
}
|
||
|
||
function _templateRow(t) {
|
||
const r = readiness(t);
|
||
const selected = state.selectedIds.has(t.adobe_id);
|
||
const warnCount = (t.warnings || []).length;
|
||
const blockCount = (t.blockers || []).length;
|
||
const fieldIssueCount = (t.field_issues || []).length;
|
||
const issueClass = blockCount > 0 ? 'blocked' : (warnCount > 0 || fieldIssueCount > 0 ? 'has-issues' : 'no-issues');
|
||
const issueLabel = blockCount > 0
|
||
? `🚫 ${blockCount} blocker${blockCount > 1 ? 's' : ''}`
|
||
: (warnCount > 0 || fieldIssueCount > 0
|
||
? `⚠ ${warnCount + fieldIssueCount} caveat${warnCount + fieldIssueCount > 1 ? 's' : ''}`
|
||
: '✓ Clean');
|
||
|
||
return `
|
||
<tr class="${selected ? 'row-selected' : ''}" data-id="${escHtml(t.adobe_id)}">
|
||
<td><input type="checkbox" class="cb row-cb" data-id="${escHtml(t.adobe_id)}" ${selected ? 'checked' : ''} /></td>
|
||
<td>
|
||
<div class="table-name tpl-name-link" data-id="${escHtml(t.adobe_id)}">${escHtml(t.name)}</div>
|
||
<div class="table-sub">${escHtml(t.adobe_id)}</div>
|
||
</td>
|
||
<td><span class="badge ${r.cls}">${r.label}</span></td>
|
||
<td><span class="issue-count ${issueClass}">${issueLabel}</span></td>
|
||
<td>${formatRelative(t.adobe_modified)}</td>
|
||
<td>
|
||
${t.docusign_id
|
||
? `<span class="badge badge-blue" title="${escHtml(t.docusign_id)}">In Docusign</span>`
|
||
: `<span class="badge badge-gray">Not Migrated</span>`}
|
||
</td>
|
||
<td>
|
||
<div class="row-actions">
|
||
<button class="btn btn-ghost btn-xs btn-migrate-one" data-id="${escHtml(t.adobe_id)}" title="Migrate this template">→ Migrate</button>
|
||
<button class="btn btn-ghost btn-xs btn-view-detail" data-id="${escHtml(t.adobe_id)}" title="View details">Detail</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
function _statusCounts(templates) {
|
||
return {
|
||
total: templates.length,
|
||
not_migrated: templates.filter(t => t.status === 'not_migrated').length,
|
||
migrated: templates.filter(t => t.status === 'migrated').length,
|
||
needs_update: templates.filter(t => t.status === 'needs_update').length,
|
||
blocked: templates.filter(t => t.blockers && t.blockers.length > 0).length,
|
||
caveats: templates.filter(t => !hasBlockers(t) && (hasWarnings(t) || hasFieldIssues(t))).length,
|
||
};
|
||
}
|
||
|
||
function _applyFilter(templates) {
|
||
let list = [...templates];
|
||
|
||
// Text search
|
||
if (_filter.search) {
|
||
const q = _filter.search.toLowerCase();
|
||
list = list.filter(t => t.name.toLowerCase().includes(q));
|
||
}
|
||
|
||
// Status / readiness filter
|
||
if (_filter.status !== 'all') {
|
||
if (_filter.status === 'blocked') {
|
||
list = list.filter(t => hasBlockers(t));
|
||
} else if (_filter.status === 'caveats') {
|
||
list = list.filter(t => !hasBlockers(t) && (hasWarnings(t) || hasFieldIssues(t)));
|
||
} else {
|
||
list = list.filter(t => t.status === _filter.status);
|
||
}
|
||
}
|
||
|
||
// Sorting
|
||
list.sort((a, b) => {
|
||
let va = a[_sort.col] || '';
|
||
let vb = b[_sort.col] || '';
|
||
if (_sort.col === 'readiness') { va = readiness(a).key; vb = readiness(b).key; }
|
||
if (_sort.col === 'warnings') { va = totalIssueCount(a); vb = totalIssueCount(b); }
|
||
if (typeof va === 'number') return _sort.dir === 'asc' ? va - vb : vb - va;
|
||
return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||
});
|
||
|
||
return list;
|
||
}
|
||
|
||
// ── Event wiring ───────────────────────────────────────────────────────────
|
||
|
||
function _bindEvents() {
|
||
bindQuickStartCard(document);
|
||
|
||
// Search
|
||
const searchEl = document.getElementById('template-search');
|
||
if (searchEl) {
|
||
searchEl.addEventListener('input', debounce(e => {
|
||
_filter.search = e.target.value;
|
||
_render();
|
||
}, 250));
|
||
}
|
||
|
||
// Filter tabs
|
||
document.querySelectorAll('.filter-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
_filter.status = btn.dataset.filter;
|
||
_render();
|
||
});
|
||
});
|
||
|
||
// Sort headers
|
||
document.querySelectorAll('th.sortable').forEach(th => {
|
||
th.addEventListener('click', () => {
|
||
const col = th.dataset.col;
|
||
if (_sort.col === col) {
|
||
_sort.dir = _sort.dir === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
_sort.col = col; _sort.dir = 'asc';
|
||
}
|
||
_render();
|
||
});
|
||
});
|
||
|
||
// Checkboxes
|
||
document.getElementById('select-all')?.addEventListener('change', e => {
|
||
const ids = _applyFilter(state.templates).map(t => t.adobe_id);
|
||
if (e.target.checked) { ids.forEach(id => state.selectedIds.add(id)); }
|
||
else { ids.forEach(id => state.selectedIds.delete(id)); }
|
||
_render();
|
||
});
|
||
|
||
document.querySelectorAll('.row-cb').forEach(cb => {
|
||
cb.addEventListener('change', e => {
|
||
const id = cb.dataset.id;
|
||
if (e.target.checked) state.selectedIds.add(id);
|
||
else state.selectedIds.delete(id);
|
||
_render();
|
||
});
|
||
});
|
||
|
||
// Migrate selected
|
||
document.getElementById('btn-migrate-selected')?.addEventListener('click', () => {
|
||
_launchMigration([...state.selectedIds]);
|
||
});
|
||
|
||
document.getElementById('btn-clear-selection')?.addEventListener('click', () => {
|
||
state.selectedIds.clear();
|
||
_render();
|
||
});
|
||
|
||
// Migrate individual
|
||
document.querySelectorAll('.btn-migrate-one').forEach(btn => {
|
||
btn.addEventListener('click', () => _launchMigration([btn.dataset.id]));
|
||
});
|
||
|
||
// View detail
|
||
document.querySelectorAll('.btn-view-detail, .tpl-name-link').forEach(el => {
|
||
el.addEventListener('click', () => navigate(`#/templates/${el.dataset.id}`));
|
||
});
|
||
|
||
// Refresh
|
||
document.getElementById('btn-refresh-templates')?.addEventListener('click', async () => {
|
||
await refreshTemplates();
|
||
_render();
|
||
});
|
||
}
|
||
|
||
async function _launchMigration(ids) {
|
||
if (!ids.length) return;
|
||
const { showOptionsModal } = await import('./migration.js');
|
||
showOptionsModal(ids);
|
||
}
|
||
|
||
// ── Template detail view ───────────────────────────────────────────────────
|
||
|
||
export async function renderTemplateDetail(adobeId) {
|
||
const outlet = document.getElementById('router-outlet');
|
||
const t = state.templates.find(t => t.adobe_id === adobeId);
|
||
|
||
if (!t) {
|
||
outlet.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">🔍</div>
|
||
<div class="empty-state-title">Template not found</div>
|
||
<div class="empty-state-sub"><a href="#/templates" class="btn btn-ghost btn-sm">← Back to Templates</a></div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
const r = readiness(t);
|
||
const issueCount = totalIssueCount(t);
|
||
outlet.innerHTML = `
|
||
<div class="page-header">
|
||
<div>
|
||
<a href="#/templates" style="font-size:12px;color:var(--cobalt);text-decoration:none">← Templates</a>
|
||
<div class="page-title" style="margin-top:4px">${escHtml(t.name)}</div>
|
||
<div class="page-subtitle">Adobe ID: <span class="mono">${escHtml(t.adobe_id)}</span></div>
|
||
</div>
|
||
<div class="page-actions">
|
||
<span class="badge ${r.cls}">${r.label}</span>
|
||
<button class="btn btn-primary btn-sm" id="detail-migrate-btn">Migrate</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs" id="detail-tabs">
|
||
<div class="tab active" data-tab="overview">Overview</div>
|
||
<div class="tab" data-tab="issues">Issues ${issueCount > 0
|
||
? `<span class="nav-badge" style="position:static;display:inline">${issueCount}</span>` : ''}</div>
|
||
<div class="tab" data-tab="history">Migration History</div>
|
||
</div>
|
||
|
||
<div id="detail-tab-content"></div>
|
||
`;
|
||
|
||
document.getElementById('detail-migrate-btn')?.addEventListener('click', () => {
|
||
import('./migration.js').then(m => m.showOptionsModal([adobeId]));
|
||
});
|
||
|
||
_bindDetailTabs(t);
|
||
_renderDetailTab(t, 'overview');
|
||
}
|
||
|
||
function _bindDetailTabs(t) {
|
||
document.querySelectorAll('#detail-tabs .tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('#detail-tabs .tab').forEach(x => x.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
_renderDetailTab(t, tab.dataset.tab);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _renderDetailTab(t, tabKey) {
|
||
const content = document.getElementById('detail-tab-content');
|
||
if (tabKey === 'overview') {
|
||
content.innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">Template Overview</span></div>
|
||
<div class="card-body">
|
||
<div class="conn-info-row">
|
||
<span class="conn-info-label">Adobe Sign ID</span>
|
||
<span class="conn-info-value">${escHtml(t.adobe_id)}</span>
|
||
</div>
|
||
<div class="conn-info-row">
|
||
<span class="conn-info-label">Last Modified</span>
|
||
<span class="conn-info-value">${formatDate(t.adobe_modified)}</span>
|
||
</div>
|
||
<div class="conn-info-row">
|
||
<span class="conn-info-label">Migration Status</span>
|
||
<span class="conn-info-value"><span class="badge badge-${t.status === 'migrated' ? 'migrated' : t.status === 'needs_update' ? 'amber' : 'gray'}">${t.status.replace('_', ' ')}</span></span>
|
||
</div>
|
||
${t.docusign_id ? `
|
||
<div class="conn-info-row">
|
||
<span class="conn-info-label">Docusign Template ID</span>
|
||
<span class="conn-info-value mono">${escHtml(t.docusign_id)}</span>
|
||
</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
} else if (tabKey === 'issues') {
|
||
const blockers = t.blockers || [];
|
||
const warnings = t.warnings || [];
|
||
const fieldIssues = t.field_issues || [];
|
||
if (!blockers.length && !warnings.length && !fieldIssues.length) {
|
||
content.innerHTML = `<div class="callout success"><span class="callout-icon">✓</span>No issues found. This template is ready to migrate.</div>`;
|
||
} else {
|
||
content.innerHTML = `
|
||
<div class="callout info">
|
||
<span class="callout-icon">ℹ️</span>
|
||
This view combines pre-migration validation with field mapping caveats. Field caveats are the same kinds of issues shown after migration.
|
||
</div>
|
||
${blockers.length ? `
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title" style="color:var(--error)">🚫 Blockers (${blockers.length})</span></div>
|
||
<div class="card-body">
|
||
${blockers.map(b => `
|
||
<div class="issue-row">
|
||
<span class="issue-severity blocker">BLOCKER</span>
|
||
<div class="issue-body"><div class="issue-title">${escHtml(b)}</div></div>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
${fieldIssues.length ? `
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title" style="color:var(--warning)">⚠ Field Mapping Caveats (${fieldIssues.length})</span></div>
|
||
<div class="card-body">
|
||
${renderFieldIssues(fieldIssues)}
|
||
</div>
|
||
</div>` : ''}
|
||
${warnings.length ? `
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title" style="color:var(--warning)">⚠ Warnings (${warnings.length})</span></div>
|
||
<div class="card-body">
|
||
${warnings.map(w => `
|
||
<div class="issue-row">
|
||
<span class="issue-severity warn">WARNING</span>
|
||
<div class="issue-body"><div class="issue-title">${escHtml(w)}</div></div>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>` : ''}`;
|
||
bindFieldIssueToggles(content);
|
||
}
|
||
} else if (tabKey === 'history') {
|
||
api.migrate.history().then(data => {
|
||
const records = (data.history || []).filter(r =>
|
||
r.adobe_template_id === t.adobe_id || r.adobe_template_name === t.name
|
||
);
|
||
if (!records.length) {
|
||
content.innerHTML = `<div class="callout info"><span class="callout-icon">ℹ️</span>No migration history for this template yet.</div>`;
|
||
} else {
|
||
const rows = [...records].reverse().map(r => {
|
||
const fieldIssues = r.field_issues || [];
|
||
const hasIssues = fieldIssues.length > 0;
|
||
const hasDetail = r.error || (r.blockers||[]).length || (r.warnings||[]).length || hasIssues;
|
||
const detailHtml = hasDetail ? `
|
||
<tr class="row-expanded-content" style="display:none">
|
||
<td colspan="4">
|
||
<div class="row-expand-body">
|
||
${(r.blockers||[]).map(b => `<div style="color:var(--error);font-size:12px">🚫 ${escHtml(b)}</div>`).join('')}
|
||
${(r.warnings||[]).map(w => `<div style="color:var(--warning);font-size:12px">⚠ ${escHtml(w)}</div>`).join('')}
|
||
${r.error ? `<div style="color:var(--error);font-size:12px">❌ ${escHtml(r.error)}</div>` : ''}
|
||
${renderFieldIssues(fieldIssues)}
|
||
</div>
|
||
</td>
|
||
</tr>` : '';
|
||
return `
|
||
<tr class="${hasDetail ? 'row-expandable' : ''}" style="${hasDetail ? 'cursor:pointer' : ''}">
|
||
<td>${(r.timestamp||'').slice(0,19).replace('T',' ')}</td>
|
||
<td>${escHtml(r.action||'—')}</td>
|
||
<td>
|
||
<span class="badge ${r.status==='success'?'badge-green':'badge-red'}">${r.status}</span>
|
||
${hasIssues ? '<span class="badge badge-amber" style="font-size:10px">partial</span>' : ''}
|
||
${hasDetail ? '<span style="font-size:10px;color:var(--text-muted);margin-left:4px">▶ click for details</span>' : ''}
|
||
</td>
|
||
<td class="mono">${escHtml(r.docusign_template_id||'—')}</td>
|
||
</tr>${detailHtml}`;
|
||
}).join('');
|
||
|
||
content.innerHTML = `
|
||
<div class="card">
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Time</th><th>Action</th><th>Status</th><th>Docusign ID</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
|
||
content.querySelectorAll('.row-expandable').forEach(row => {
|
||
row.addEventListener('click', () => {
|
||
const next = row.nextElementSibling;
|
||
if (next?.classList.contains('row-expanded-content')) {
|
||
const open = next.style.display !== 'none';
|
||
next.style.display = open ? 'none' : 'table-row';
|
||
const hint = row.querySelector('span[style*="text-muted"]');
|
||
if (hint) hint.textContent = open ? '▶ click for details' : '▼ hide details';
|
||
}
|
||
});
|
||
});
|
||
bindFieldIssueToggles(content);
|
||
}
|
||
}).catch(() => {
|
||
content.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history.</div>`;
|
||
});
|
||
}
|
||
}
|
||
|
||
function hasBlockers(t) {
|
||
return (t.blockers || []).length > 0;
|
||
}
|
||
|
||
function hasWarnings(t) {
|
||
return (t.warnings || []).length > 0;
|
||
}
|
||
|
||
function hasFieldIssues(t) {
|
||
return (t.field_issues || []).length > 0;
|
||
}
|
||
|
||
function totalIssueCount(t) {
|
||
return (t.blockers || []).length + (t.warnings || []).length + (t.field_issues || []).length;
|
||
}
|