feat: render dossier timelines and exceptions

This commit is contained in:
Repinoid
2026-10-31 10:34:02 +03:00
parent 18730bf93e
commit 5d8d57797e
4 changed files with 212 additions and 2 deletions
+170
View File
@@ -27,6 +27,16 @@
* Вызывается из runClassify() после успешной классификации.
*/
async function loadGroupsAction() {
if (state.v3Mode) {
var v3Response = await fetch('/api/dossiers?batch_id=' + encodeURIComponent(state.batchId));
var v3Data = await v3Response.json();
if (!v3Data.ok) return;
state.dossiers = v3Data.dossiers || [];
renderDossiers(state);
stepDone('stepGroups');
return;
}
var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId);
var data = await resp.json();
if (!data.ok || !data.groups) return;
@@ -115,6 +125,166 @@ function renderGroups(state) {
lucide.createIcons();
}
function renderDossiers(appState) {
var diffBody = document.getElementById('diffBody');
var diffCard = document.getElementById('diffCard');
diffCard.style.display = 'block';
var summary = appState.v3Summary || {};
var html = '<div style="font-weight:600;margin-bottom:8px;">Досье: ' + appState.dossiers.length +
' | требуется связать: ' + (summary.ambiguous || 0) + '</div>';
appState.dossiers.forEach(function(dossier) {
var aliases = (dossier.alias_numbers || []).map(escHtml).join(', ');
var docs = (dossier.documents || []).map(function(doc) {
return '<div style="padding:3px 0;font-size:12px;">' +
'<span style="color:var(--muted);">' + escHtml(doc.role || 'document') + '</span> ' +
escHtml(doc.filename || '') +
' <span style="color:' + (doc.math_status === 'VERIFIED' ? 'var(--green)' : 'var(--muted)') + ';">' +
(doc.math_status === 'VERIFIED' ? '✓ арифметика' : '⚠ ' + escHtml(doc.math_status || 'UNKNOWN')) + '</span>' +
(doc.doc_date ? ' <span style="color:var(--muted);">' + escHtml(doc.doc_date) + '</span>' : '') +
'</div>';
}).join('');
html += '<section style="border-top:1px solid var(--brand-gray);padding:10px 0;">' +
'<div style="font-weight:600;">Договор №' + escHtml(dossier.canonical_number || '—') +
' — ' + escHtml(dossier.counterparty_name || 'контрагент не определён') + '</div>' +
(aliases ? '<div style="font-size:12px;color:var(--muted);">Алиасы: ' + aliases + '</div>' : '') +
(!dossier.has_frame_contract ? '<div style="font-size:12px;color:var(--muted);">Рамочный договор не загружен</div>' : '') +
docs +
'<button class="btn btn-primary timeline-btn" type="button" data-dossier-id="' + escHtml(dossier.id) + '" style="margin-top:8px;">' +
'<i data-lucide="list-tree" style="width:16px;height:16px;"></i> Построить таймлайн</button>' +
'<div class="dossier-timeline" id="dossierTimeline_' + escHtml(dossier.id) + '" style="margin-top:8px;"></div>' +
'</section>';
});
if (summary.unlinked && summary.unlinked.length) {
html += '<div style="margin-top:8px;color:var(--destructive);">Не привязано документов: ' +
summary.unlinked.length + '</div>';
}
diffBody.innerHTML = html;
diffBody.querySelectorAll('.timeline-btn').forEach(function(button) {
button.addEventListener('click', function() {
loadDossierTimelineAction(this.getAttribute('data-dossier-id'), this);
});
});
lucide.createIcons();
}
function loadDossierTimelineAction(dossierId, button) {
var container = document.getElementById('dossierTimeline_' + dossierId);
var oldStatus = container.previousElementSibling;
if (oldStatus && oldStatus.classList.contains('dossier-status')) oldStatus.remove();
var status = document.createElement('div');
status.className = 'dossier-status';
status.style.cssText = 'font-size:12px;color:var(--muted);margin:6px 0;';
status.textContent = 'Построение таймлайна...';
container.parentNode.insertBefore(status, container);
container.innerHTML = '';
button.disabled = true;
var stream = startCompareSSE(
'/process-v2?dossier_id=' + encodeURIComponent(dossierId),
container,
status,
{
onTimeline: function(data) {
state.dossierTimelines[dossierId] = data;
renderDossierTimeline(dossierId, container, data);
},
onDone: function() {
button.disabled = false;
button.innerHTML = '<i data-lucide="list-tree" style="width:16px;height:16px;"></i> Обновить таймлайн';
status.textContent = 'Таймлайн обновлён';
lucide.createIcons();
},
onError: function(data) {
button.disabled = false;
status.textContent = 'Ошибка построения';
container.innerHTML = '<div style="color:var(--destructive);">' + escHtml(data.message) + '</div>';
},
onConnectionError: function() {
button.disabled = false;
status.textContent = 'Ошибка соединения';
container.innerHTML = '<div style="color:var(--destructive);">Ошибка соединения</div>';
}
},
);
state._activeCompare.es = stream.es;
state._activeCompare.timer = stream.timer;
}
function renderDossierTimeline(dossierId, container, data, asOf) {
asOf = asOf || new Date().toISOString().slice(0, 10);
var active = (data.timeline || []).filter(function(interval) {
return interval.valid_from && interval.valid_from <= asOf &&
(!interval.valid_to || interval.valid_to > asOf);
});
var byService = {};
(data.timeline || []).forEach(function(interval) {
var key = interval.service_item_id || interval.id;
(byService[key] || (byService[key] = [])).push(interval);
});
var html = '<label style="display:flex;align-items:center;gap:8px;font-size:12px;margin:8px 0;">' +
'Действующие услуги на дату <input class="timeline-date" type="date" value="' + escHtml(asOf) + '" style="height:30px;border:1px solid var(--brand-gray);border-radius:4px;padding:2px 6px;"></label>' +
'<div style="font-size:12px;color:var(--muted);margin-bottom:8px;">Активно: ' + active.length + ' интервалов</div>';
Object.keys(byService).forEach(function(serviceId) {
var intervals = byService[serviceId].slice().sort(function(left, right) {
return String(left.valid_from || '').localeCompare(String(right.valid_from || ''));
});
html += '<section style="border-top:1px solid var(--brand-gray);padding:8px 0;">' +
'<div style="font-weight:600;font-size:13px;">' + escHtml(intervals[0].raw_name || 'Услуга') + '</div>';
intervals.forEach(function(interval) {
var isActive = interval.valid_from && interval.valid_from <= asOf &&
(!interval.valid_to || interval.valid_to > asOf);
html += '<div style="font-size:12px;padding:3px 0;' + (isActive ? 'color:var(--green);font-weight:600;' : '') + '">[' + escHtml(interval.valid_from || '—') + ' — ' +
escHtml(interval.valid_to || '∞') + ') · ' + escHtml(interval.charge_type || 'unknown') +
' · цена ' + escHtml(interval.price || '—') + ' · кол-во ' + escHtml(interval.qty || '—') +
' · сумма ' + escHtml(interval.sum || '—') + (isActive ? ' · действует' : '') + '</div>';
});
html += '</section>';
});
if (data.exceptions && data.exceptions.length) {
html += '<section style="border-top:1px solid var(--brand-gray);padding:8px 0;">' +
'<div style="font-weight:600;color:var(--destructive);">Очередь исключений (' + data.exceptions.length + ')</div>';
data.exceptions.forEach(function(issue) {
html += '<div style="padding:6px 0;border-bottom:1px solid var(--brand-gray);">' +
'<div>' + escHtml(issue.title || issue.stage) + '</div>' +
(issue.source_quote ? '<div style="font-size:12px;color:var(--muted);">' + escHtml(issue.source_quote) + '</div>' : '');
(issue.options || []).forEach(function(option, index) {
var label = option.canonical_name || option.service_family || ('Вариант ' + (index + 1));
if (option.instance_anchor) label += ' · ' + option.instance_anchor;
html += '<button class="btn exception-choice" type="button" data-exception-id="' + escHtml(issue.id) +
'" data-option-index="' + index + '" style="margin:4px 4px 0 0;font-size:12px;min-height:32px;">' +
escHtml(label) + '</button>';
});
html += '</div>';
});
html += '</section>';
}
container.innerHTML = html;
container.querySelector('.timeline-date').addEventListener('change', function() {
renderDossierTimeline(dossierId, container, data, this.value);
});
container.querySelectorAll('.exception-choice').forEach(function(choice) {
choice.addEventListener('click', async function() {
var response = await fetch('/api/exceptions/' + encodeURIComponent(this.dataset.exceptionId) + '/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolution: { option_index: Number(this.dataset.optionIndex) } })
});
var result = await response.json();
if (!result.ok) {
this.disabled = false;
this.title = result.error || 'Не удалось сохранить решение';
return;
}
var button = document.querySelector('.timeline-btn[data-dossier-id="' + dossierId + '"]');
loadDossierTimelineAction(dossierId, button);
});
});
}
/**
* renderUnresolvedCard(group) — Чистая HTML-функция: карточка нераспознанных файлов.
*