v1.0.178: Фаза 2 — groups.js. loadGroupsAction + renderGroups + renderGroupCard + renderGroupCardDone. markGroupDone удалена, готовность = group.compare.status === 'done'. renderGroups пересобирает ВСЕ карточки из state.groups.
This commit is contained in:
+16
-115
@@ -135,7 +135,7 @@ async function runClassify() {
|
||||
btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||||
stepDone('stepClassify');
|
||||
stepActive('stepGroups');
|
||||
loadGroups();
|
||||
loadGroupsAction();
|
||||
} else {
|
||||
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
|
||||
btn.disabled = false;
|
||||
@@ -148,118 +148,7 @@ async function runClassify() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId);
|
||||
var data = await resp.json();
|
||||
if (!data.ok || !data.groups) return;
|
||||
|
||||
var diffBody = document.getElementById('diffBody');
|
||||
var diffCard = document.getElementById('diffCard');
|
||||
diffCard.style.display = 'block';
|
||||
var realGroups = data.groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
|
||||
var unresolvedGroup = data.groups.find(function(g) { return g.contract_number === '__unresolved__'; });
|
||||
diffBody.innerHTML = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length + (unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') + '</div>';
|
||||
stepDone('stepGroups');
|
||||
|
||||
// Сохраняем группы для compare
|
||||
state.groups = data.groups;
|
||||
|
||||
data.groups.forEach(function(g, gi) {
|
||||
var isUnresolved = g.contract_number === '__unresolved__';
|
||||
var card = document.createElement('div');
|
||||
card.style.cssText = 'border:1px solid ' + (isUnresolved ? 'var(--destructive)' : 'var(--brand-gray)') + ';border-radius:6px;padding:10px;margin-bottom:8px;';
|
||||
|
||||
if (isUnresolved) {
|
||||
var docsHtml = g.documents.map(function(d) {
|
||||
var reason = '';
|
||||
if (d.parent_number) {
|
||||
reason = ' — нет базового договора №' + escHtml(d.parent_number);
|
||||
} else if (d.classify_status === 'failed') {
|
||||
reason = ' — ошибка классификации: ' + escHtml(d.error_message || '?');
|
||||
} else if (!d.doc_type || d.doc_type === 'other') {
|
||||
reason = ' — тип не определён';
|
||||
} else {
|
||||
reason = ' — нет matching-договора';
|
||||
}
|
||||
return '<div style="padding:2px 0;">' +
|
||||
'<span style="color:var(--muted);">[' + escHtml(d.doc_type || '?') + ']</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'<span style="color:var(--destructive);font-size:11px;">' + reason + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
card.innerHTML = '<div style="font-weight:600;margin-bottom:6px;">❓ Не распознано (' + g.documents.length + ' файлов)</div>' +
|
||||
'<div style="font-size:12px;">' + docsHtml + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Загрузите недостающие базовые договоры для этих номеров.</div>';
|
||||
} else {
|
||||
buildGroupCard(g, gi, card);
|
||||
}
|
||||
diffBody.appendChild(card);
|
||||
});
|
||||
// Один обработчик на все кнопки сравнения
|
||||
diffBody.querySelectorAll('.cmp-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var gi = parseInt(this.getAttribute('data-gi'));
|
||||
runCompareForGroup(gi, this);
|
||||
});
|
||||
});
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// ── Карточка необработанной группы ──────────────────────────
|
||||
function buildGroupCard(g, gi, card) {
|
||||
card.innerHTML = '<div class="cmp-header" style="font-weight:600;margin-bottom:6px;">' +
|
||||
'📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') +
|
||||
'</div>' +
|
||||
'<div style="font-size:12px;">' +
|
||||
g.documents.map(function(d) {
|
||||
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||||
return '<div style="padding:2px 0;">' +
|
||||
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('') + '</div>' +
|
||||
'<button class="btn btn-primary cmp-btn" style="margin-top:6px;font-size:12px;height:28px;" data-gi="' + gi + '">▶ Сравнить эту группу</button>';
|
||||
// Необработанная: только кнопка, ничего больше
|
||||
}
|
||||
|
||||
// ── Заменить карточку на обработанную ───────────────────────
|
||||
function markGroupDone(gi, time) {
|
||||
var bodyEl = document.getElementById('cmpBody_' + gi);
|
||||
var bodyHTML = bodyEl ? bodyEl.innerHTML : '';
|
||||
var card = bodyEl.parentNode;
|
||||
var g = state.groups[gi];
|
||||
card.innerHTML = '<div class="cmp-header" style="font-weight:600;margin-bottom:6px;cursor:pointer;">' +
|
||||
'<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' +
|
||||
'📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') +
|
||||
' <span style="font-weight:400;color:var(--green);font-size:11px;">✓ Готово (' + time + ')</span>' +
|
||||
'</div>' +
|
||||
'<div style="font-size:12px;">' +
|
||||
g.documents.map(function(d, di) {
|
||||
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||||
return '<div style="padding:2px 0;cursor:pointer;" onclick="var b=document.querySelectorAll(\'#cmpBody_' + gi + ' .diff-section-body\');if(b[' + di + '])b[' + di + '].style.display=b[' + di + '].style.display===\'none\'?\'block\':\'none\';">' +
|
||||
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('') + '</div>' +
|
||||
'<div class="cmp-body" id="cmpBody_' + gi + '" style="margin-top:8px;">' + bodyHTML + '</div>';
|
||||
// Клик на заголовок → свернуть/развернуть
|
||||
card.querySelector('.cmp-header').addEventListener('click', function() {
|
||||
var body = document.getElementById('cmpBody_' + gi);
|
||||
var arrow = document.getElementById('cmpArrow_' + gi);
|
||||
if (body) {
|
||||
if (body.style.display === 'none') {
|
||||
body.style.display = 'block';
|
||||
if (arrow) arrow.textContent = '▾';
|
||||
} else {
|
||||
body.style.display = 'none';
|
||||
if (arrow) arrow.textContent = '▸';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// loadGroups, buildGroupCard, markGroupDone → groups.js (Фаза 2)
|
||||
|
||||
// Текущий активный EventSource (только один одновременно)
|
||||
// state._activeCompare — теперь в state.js (Фаза 0: state + render)
|
||||
@@ -268,6 +157,10 @@ window.runCompareForGroup = async function(gi, btn) {
|
||||
var group = state.groups[gi];
|
||||
if (!group || group.contract_number === '__unresolved__') return;
|
||||
|
||||
// Фаза 2: отметить группу как «в процессе»
|
||||
// renderGroups НЕ вызываем — инкрементальные DOM-мутации внутри SSE
|
||||
group.compare = { status: 'running' };
|
||||
|
||||
// Остановить предыдущее сравнение
|
||||
if (state._activeCompare.es) { state._activeCompare.es.close(); state._activeCompare.es = null; }
|
||||
if (state._activeCompare.timer) { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; }
|
||||
@@ -363,8 +256,14 @@ window.runCompareForGroup = async function(gi, btn) {
|
||||
es.close(); state._activeCompare.es = null;
|
||||
statusEl.textContent = '✓ ' + d.total_time_s + 'с';
|
||||
statusEl.style.color = 'var(--green)';
|
||||
// Заменить карточку на обработанную (другая вёрстка, нет кнопок)
|
||||
markGroupDone(gi, d.total_time_s + 'с');
|
||||
// Фаза 2: вместо markGroupDone — флаг в state + пересборка карточек
|
||||
// renderGroups перестроит карточку как «готовую» (renderGroupCardDone)
|
||||
group.compare = {
|
||||
status: 'done',
|
||||
totalTime: d.total_time_s + 'с',
|
||||
bodyHTML: cmpBody.innerHTML
|
||||
};
|
||||
renderGroups(state);
|
||||
// Разблокировать остальные кнопки
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
stepDone('stepCompare');
|
||||
@@ -372,6 +271,7 @@ window.runCompareForGroup = async function(gi, btn) {
|
||||
else if (d.type === 'error') {
|
||||
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
|
||||
es.close(); state._activeCompare.es = null;
|
||||
group.compare = null; // Фаза 2: сбросить статус группы при ошибке
|
||||
cmpBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + d.message + '</div>';
|
||||
btn.innerHTML = '✗'; btn.disabled = false;
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
@@ -380,6 +280,7 @@ window.runCompareForGroup = async function(gi, btn) {
|
||||
es.onerror = function() {
|
||||
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
|
||||
es.close(); state._activeCompare.es = null;
|
||||
group.compare = null; // Фаза 2: сбросить статус группы при ошибке
|
||||
btn.innerHTML = '✗ соединение'; btn.disabled = false;
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* groups.js — Модуль групп и карточек договоров (Фаза 2, decoupling-final-plan.md).
|
||||
*
|
||||
* ВЫНЕСЕНО из app.js:
|
||||
* - loadGroupsAction() — fetch групп, сохранить в state.groups, renderGroups
|
||||
* - renderGroups(state) — пересобрать ВСЕ карточки групп в diffBody
|
||||
* - renderGroupCard(g, gi) — чистая HTML-функция: карточка необработанной группы
|
||||
* - renderGroupCardDone(g, gi)— чистая HTML-функция: карточка обработанной группы
|
||||
*
|
||||
* ПАТТЕРН (decoupling-final-plan.md):
|
||||
* renderGroups пересобирает ВСЕ карточки целиком из state.groups.
|
||||
* Готовность группы: group.compare.status === 'done' (вместо markGroupDone).
|
||||
* Никакого window._groupsData — всё в state.groups.
|
||||
*
|
||||
* ЗАВИСИМОСТИ:
|
||||
* state.js → state (центральное состояние)
|
||||
* app_utils.js → escHtml
|
||||
*/
|
||||
|
||||
/**
|
||||
* loadGroupsAction() — Загрузить группы с бэкенда (Фаза 2).
|
||||
*
|
||||
* 1. fetch /api/groups?batch=...
|
||||
* 2. Сохранить в state.groups (бывший window._groupsData)
|
||||
* 3. Вызвать renderGroups(state) — пересобрать карточки
|
||||
*
|
||||
* Вызывается из runClassify() после успешной классификации.
|
||||
*/
|
||||
async function loadGroupsAction() {
|
||||
var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId);
|
||||
var data = await resp.json();
|
||||
if (!data.ok || !data.groups) return;
|
||||
|
||||
// Сохраняем в центральное состояние (вместо window._groupsData)
|
||||
state.groups = data.groups;
|
||||
|
||||
// Пересобрать ВСЕ карточки
|
||||
renderGroups(state);
|
||||
|
||||
stepDone('stepGroups');
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGroups(state) — Пересобрать ВСЕ карточки групп (Фаза 2).
|
||||
*
|
||||
* ПЕРЕСОБИРАЕТ весь diffBody.innerHTML заново из state.groups.
|
||||
* Никакого инкрементального обновления — всегда полная перестройка.
|
||||
*
|
||||
* Для каждой группы:
|
||||
* - Необработанная (group.compare.status !== 'done') → renderGroupCard()
|
||||
* - Обработанная (group.compare.status === 'done') → renderGroupCardDone()
|
||||
* - Нераспознанная (contract_number === '__unresolved__') → отдельный рендер
|
||||
*/
|
||||
function renderGroups(state) {
|
||||
var groups = state.groups;
|
||||
if (!groups) return;
|
||||
|
||||
var diffBody = document.getElementById('diffBody');
|
||||
var diffCard = document.getElementById('diffCard');
|
||||
diffCard.style.display = 'block';
|
||||
|
||||
var realGroups = groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
|
||||
var unresolvedGroup = groups.find(function(g) { return g.contract_number === '__unresolved__'; });
|
||||
|
||||
// Заголовок: количество групп + нераспознанные
|
||||
var html = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length +
|
||||
(unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') +
|
||||
'</div>';
|
||||
|
||||
// Нераспознанная группа — всегда первая
|
||||
if (unresolvedGroup) {
|
||||
html += renderUnresolvedCard(unresolvedGroup);
|
||||
}
|
||||
|
||||
// Карточки обычных групп
|
||||
groups.forEach(function(g, gi) {
|
||||
if (g.contract_number === '__unresolved__') return;
|
||||
if (g.compare && g.compare.status === 'done') {
|
||||
html += renderGroupCardDone(g, gi);
|
||||
} else {
|
||||
html += renderGroupCard(g, gi);
|
||||
}
|
||||
});
|
||||
|
||||
diffBody.innerHTML = html;
|
||||
|
||||
// Один обработчик на все кнопки сравнения (event delegation не используется —
|
||||
// кнопки пересоздаются при каждом renderGroups)
|
||||
diffBody.querySelectorAll('.cmp-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var gi = parseInt(this.getAttribute('data-gi'));
|
||||
runCompareForGroup(gi, this);
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики сворачивания для готовых карточек
|
||||
diffBody.querySelectorAll('.cmp-header').forEach(function(header) {
|
||||
var gi = parseInt(header.getAttribute('data-gi'));
|
||||
if (isNaN(gi)) return;
|
||||
header.addEventListener('click', function() {
|
||||
var body = document.getElementById('cmpBody_' + gi);
|
||||
var arrow = document.getElementById('cmpArrow_' + gi);
|
||||
if (body) {
|
||||
if (body.style.display === 'none') {
|
||||
body.style.display = 'block';
|
||||
if (arrow) arrow.textContent = '▾';
|
||||
} else {
|
||||
body.style.display = 'none';
|
||||
if (arrow) arrow.textContent = '▸';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* renderUnresolvedCard(group) — Чистая HTML-функция: карточка нераспознанных файлов.
|
||||
*
|
||||
* Вход: group — { contract_number: '__unresolved__', documents: [...] }
|
||||
* Выход: HTML-строка
|
||||
*/
|
||||
function renderUnresolvedCard(group) {
|
||||
var docsHtml = group.documents.map(function(d) {
|
||||
// Определить причину, почему файл не попал ни в одну группу
|
||||
var reason = '';
|
||||
if (d.parent_number) {
|
||||
reason = ' — нет базового договора №' + escHtml(d.parent_number);
|
||||
} else if (d.classify_status === 'failed') {
|
||||
reason = ' — ошибка классификации: ' + escHtml(d.error_message || '?');
|
||||
} else if (!d.doc_type || d.doc_type === 'other') {
|
||||
reason = ' — тип не определён';
|
||||
} else {
|
||||
reason = ' — нет matching-договора';
|
||||
}
|
||||
return '<div style="padding:2px 0;">' +
|
||||
'<span style="color:var(--muted);">[' + escHtml(d.doc_type || '?') + ']</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'<span style="color:var(--destructive);font-size:11px;">' + reason + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
return '<div style="border:1px solid var(--destructive);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div style="font-weight:600;margin-bottom:6px;">❓ Не распознано (' + group.documents.length + ' файлов)</div>' +
|
||||
'<div style="font-size:12px;">' + docsHtml + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Загрузите недостающие базовые договоры для этих номеров.</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGroupCard(group, gi) — Чистая HTML-функция: необработанная группа (Фаза 2).
|
||||
*
|
||||
* Показывает: номер договора, контрагента, список документов, кнопку «Сравнить».
|
||||
* Если группа в процессе сравнения (group.compare.status === 'running') — кнопка disabled.
|
||||
*
|
||||
* Вход: group — элемент state.groups
|
||||
* gi — индекс группы (для data-gi атрибута кнопки)
|
||||
* Выход: HTML-строка
|
||||
*/
|
||||
function renderGroupCard(group, gi) {
|
||||
var isRunning = group.compare && group.compare.status === 'running';
|
||||
|
||||
var html = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;">' +
|
||||
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
|
||||
'</div>' +
|
||||
'<div style="font-size:12px;">' +
|
||||
group.documents.map(function(d) {
|
||||
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||||
return '<div style="padding:2px 0;">' +
|
||||
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('') + '</div>' +
|
||||
'<button class="btn btn-primary cmp-btn" style="margin-top:6px;font-size:12px;height:28px;" data-gi="' + gi + '"' +
|
||||
(isRunning ? ' disabled' : '') + '>▶ Сравнить эту группу</button>' +
|
||||
'</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGroupCardDone(group, gi) — Чистая HTML-функция: обработанная группа (Фаза 2).
|
||||
*
|
||||
* Показывает: ✓ Готово (время), список документов (кликабельные — раскрывают секции),
|
||||
* тело сравнения (cmpBody) — сворачивается по клику на заголовок.
|
||||
*
|
||||
* Вход: group — элемент state.groups (group.compare.status === 'done')
|
||||
* gi — индекс группы
|
||||
* Выход: HTML-строка
|
||||
*
|
||||
* ЗАМЕНЯЕТ удалённую markGroupDone().
|
||||
* Готовность определяется по group.compare.status, а не по наличию кнопки.
|
||||
*/
|
||||
function renderGroupCardDone(group, gi) {
|
||||
var time = group.compare.totalTime || '?с';
|
||||
var bodyHTML = group.compare.bodyHTML || '';
|
||||
|
||||
var html = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;cursor:pointer;" data-gi="' + gi + '">' +
|
||||
'<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' +
|
||||
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
|
||||
' <span style="font-weight:400;color:var(--green);font-size:11px;">✓ Готово (' + time + ')</span>' +
|
||||
'</div>' +
|
||||
'<div style="font-size:12px;">' +
|
||||
group.documents.map(function(d, di) {
|
||||
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||||
return '<div style="padding:2px 0;cursor:pointer;" onclick="var b=document.querySelectorAll(\'#cmpBody_' + gi + ' .diff-section-body\');if(b[' + di + '])b[' + di + '].style.display=b[' + di + '].style.display===\'none\'?\'block\':\'none\';">' +
|
||||
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('') + '</div>' +
|
||||
'<div class="cmp-body" id="cmpBody_' + gi + '" style="margin-top:8px;">' + bodyHTML + '</div>' +
|
||||
'</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
Reference in New Issue
Block a user