feat: render dossier timelines and exceptions
This commit is contained in:
@@ -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-функция: карточка нераспознанных файлов.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user