{emailQuery.trim() ? "Ничего не найдено" : "Заявок пока нет"}
) : (
)}
);
}
/* ---------- Новый расчёт ---------- */
function Seg({ options, value, onChange }) {
return (
{options.map((o) => (
))}
);
}
const _HEADER_FIELD_RU = {
pos: "Поз.", mark: "Марка", designation: "Обознач.", name: "Наимен.",
opening_size: "Проём", qty: "Кол-во", description: "Описание", note: "Примеч.",
scheme: "Схема (не читаем)",
};
// Полные подписи полей для построчного экрана проверки после распознавания —
// то же самое, что _HEADER_FIELD_LABELS в parser.py, плюс 'text'/'note' для
// кусков без разметки шапки (там одно общее поле, а не подколонки).
const _FIELD_LABELS_FULL = {
pos: "Поз.", mark: "Маркировка", designation: "Обозначение", name: "Наименование",
opening_size: "Размер (по проёму)", qty: "Количество", description: "Описание",
note: "Примечание", text: "Текст",
};
function _assembleFieldsText(fields) {
return Object.entries(fields || {})
.map(([field, text]) => `${_FIELD_LABELS_FULL[field] || field}: ${text}`)
.join("; ");
}
function ZoomIcon({ zoomed }) {
return (
);
}
// Все ключи "ci,ri" разметки — используется как дефолтный набор excluded
// (по умолчанию исключено ВСЁ, менеджер явно отмечает нужное).
function _allPieceKeys(layout) {
const keys = [];
for (let ci = 0; ci < layout.columns.length - 1; ci++) {
for (let ri = 0; ri < layout.rows[ci].length - 1; ri++) keys.push(`${ci},${ri}`);
}
return new Set(keys);
}
// Режимы разметки — переключаются одной кнопкой в общей панели над списком
// страниц (см. PhotoSplitToolbar), не тапом по каждому куску отдельно.
// "Дверь"/"Шапка"/"Примечание" — тап по куску ставит/снимает именно этот тег
// (повторный тап тем же тегом снимает его, кусок снова нейтральный/excluded).
// "Разметка" — тап по куску ничего не делает, только перетаскивание линий.
// "Линии" — тап внутри куска режет его в этой точке (как раньше двойной клик).
const PHOTO_TAG_MODES = ["Дверь", "Шапка", "Примечание", "Разметка", "Линии"];
const _TAG_MODE_TO_KEY = { "Дверь": "door", "Шапка": "header", "Примечание": "note" };
function PhotoSplitEditor({ item, onChange, mode }) {
const [hoverKey, setHoverKey] = usePagesState(null);
const [zoomed, setZoomed] = usePagesState(false);
const [headerBusy, setHeaderBusy] = usePagesState(new Set());
const itemRef = React.useRef(item);
itemRef.current = item;
const headerQueueRef = React.useRef(Promise.resolve());
const { layout, excluded, notes, header = new Set(), headerFields = {} } = item;
const { columns, rows } = layout;
const maxW = zoomed ? Math.min(1600, window.innerWidth - 96) : 640;
const scale = Math.min(1, maxW / layout.width);
const dispW = Math.round(layout.width * scale);
const dispH = Math.round(layout.height * scale);
const setLayout = (updater) => onChange({ ...item, layout: updater(layout) });
// Менеджер отметил кусок как «Шапка» — режем его на подколонки и сопоставляем
// каждую с полем по ключевому слову в подписи (Марка/Обозначение/Кол-во и т.д.),
// целиком кодом, без ИИ (см. detect_header_fields в parser.py). Найденные X-границы
// дальше применяются ко всем строкам данных этой же половины страницы — код
// заранее знает, какой текст относится к какому полю, вместо того чтобы гадать
// по формату прозы.
// Если менеджер отмечает шапку в обеих половинах страницы быстро подряд, два
// запроса летят почти одновременно — тот, что завершится вторым, читает
// itemRef.current ДО того как первый успел применить свой onChange, и может
// затереть его результат (гонка состояний). Прогоняем через одну очередь —
// следующий вызов ждёт, пока предыдущий полностью применит свой onChange,
// прежде чем начать (headerQueueRef — цепочка промисов, а не два в параллель).
const detectHeaderFields = (ci, x0, y0, x1, y1) => {
headerQueueRef.current = headerQueueRef.current.then(async () => {
setHeaderBusy((s) => new Set(s).add(ci));
try {
const fd = new FormData();
if (item.token) fd.append("file_token", item.token);
else fd.append("file", item.file);
fd.append("x0", x0); fd.append("y0", y0); fd.append("x1", x1); fd.append("y1", y1);
const r = await fetchWithRetry("/api/detect-header-fields", { method: "POST", body: fd });
if (!r.ok) throw new Error(await r.text());
const res = await r.json();
const cur = itemRef.current;
onChange({ ...cur, headerFields: { ...cur.headerFields, [ci]: res.fields } });
} catch (e) {
console.error("detect-header-fields", e);
} finally {
setHeaderBusy((s) => { const next = new Set(s); next.delete(ci); return next; });
}
});
};
// Сдвигает ключи "ci,ri" в excluded/notes/header при вставке/удалении границы
// строки в колонке colIdx — иначе после разрезания/объединения кусков пометки
// "поедут" не на те куски. Кусок(и) на месте самой операции (splitAtRi при
// разрезании, mergedRi/lineIdx при объединении) сбрасываются в обычный —
// предсказуемее, чем гадать, какую из двух старых пометок сохранить.
const _reindexRowKeys = (colIdx, dropRis, shiftFn) => (s) => {
const next = new Set();
for (const k of s) {
const [kc, kr] = k.split(",").map(Number);
if (kc !== colIdx) { next.add(k); continue; }
if (dropRis.includes(kr)) continue;
next.add(`${kc},${shiftFn(kr)}`);
}
return next;
};
// Двойной клик внутри куска — разрезать его пополам в месте клика (когда
// авто-разметка склеила несколько настоящих позиций в один кусок). Это
// правка разметки, не отметка — обе новые половины должны стать нейтральными
// (excluded), а не унаследовать что-то по умолчанию (см. историю: раньше
// "без тега" само по себе значило "дверь", теперь означает excluded —
// reindex сам по себе только вычищает старые ключи, но новые в excluded
// не добавляет, поэтому добавляем явно).
const splitPiece = (colIdx, ri, clickY) => {
const rowsArr = rows[colIdx];
const top = rowsArr[ri], bot = rowsArr[ri + 1];
const y = Math.round(Math.max(top + 10, Math.min(bot - 10, clickY)));
if (y <= top + 10 || y >= bot - 10) return;
const newRows = layout.rows.map((r, i) => (i === colIdx ? [...r.slice(0, ri + 1), y, ...r.slice(ri + 1)] : r));
const reindex = _reindexRowKeys(colIdx, [ri], (kr) => (kr > ri ? kr + 1 : kr));
const nextExcluded = reindex(excluded);
nextExcluded.add(`${colIdx},${ri}`);
nextExcluded.add(`${colIdx},${ri + 1}`);
onChange({
...item,
layout: { ...layout, rows: newRows },
excluded: nextExcluded, notes: reindex(notes), header: reindex(header),
});
};
// Клик (без перетаскивания) по линии между кусками — убрать её, объединив
// соседние куски в один (когда авто-разметка нарезала одну позицию на много
// кусков, напр. таблицы с полями "Ширина/Высота/..." по отдельным строкам).
// Тоже правка разметки, не отметка — объединённый кусок должен стать
// нейтральным (excluded), а не "дверью" по умолчанию (см. splitPiece выше).
const removeRowLine = (colIdx, lineIdx) => {
const newRows = layout.rows.map((r, i) => (i === colIdx ? r.filter((_, j) => j !== lineIdx) : r));
const reindex = _reindexRowKeys(colIdx, [lineIdx - 1, lineIdx], (kr) => (kr > lineIdx ? kr - 1 : kr));
const nextExcluded = reindex(excluded);
nextExcluded.add(`${colIdx},${lineIdx - 1}`);
onChange({
...item,
layout: { ...layout, rows: newRows },
excluded: nextExcluded, notes: reindex(notes), header: reindex(header),
});
};
// Сбросить разметку куска целиком — колонка становится одним куском на всю
// высоту (снова нейтральным/excluded — см. _allPieceKeys), дальше можно
// расставить границы и отметить нужное заново.
const resetSplit = () => {
const newRows = columns.slice(0, -1).map(() => [0, layout.height]);
onChange({
...item,
layout: { ...layout, rows: newRows },
excluded: new Set(newRows.map((_, ci) => `${ci},0`)),
notes: new Set(), header: new Set(), headerFields: {},
});
};
// boundaryIdx — индекс внутренней границы в columns (1 .. length-2). Лист
// может делиться не на 2, а на N колонок (реальный случай — 3 таблицы в
// ряд) — на каждую внутреннюю границу свой перетаскиваемый разделитель,
// двигаем только его, зажатый соседними границами, чтобы колонки не
// могли поменяться местами/схлопнуться.
const startDragCol = (boundaryIdx) => (e) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startVal = columns[boundaryIdx];
const minX = columns[boundaryIdx - 1] + 20;
const maxX = columns[boundaryIdx + 1] - 20;
const onMove = (ev) => {
const dx = (ev.clientX - startX) / scale;
const next = Math.round(Math.max(minX, Math.min(maxX, startVal + dx)));
setLayout((l) => ({
...l,
columns: l.columns.map((v, i) => (i === boundaryIdx ? next : v)),
}));
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
const startDragRow = (colIdx, lineIdx) => (e) => {
e.preventDefault();
e.stopPropagation();
const startY = e.clientY;
const rowsArr = rows[colIdx];
const startVal = rowsArr[lineIdx];
const minY = rowsArr[lineIdx - 1] + 10;
const maxY = rowsArr[lineIdx + 1] - 10;
let moved = false; // порог в экранных px, не зависит от масштаба — иначе при
// отдалённом виде (scale<1) любое дрожание руки уже считалось бы перетаскиванием
const onMove = (ev) => {
if (Math.abs(ev.clientY - startY) > 4) moved = true;
const dy = (ev.clientY - startY) / scale;
const next = Math.round(Math.max(minY, Math.min(maxY, startVal + dy)));
setLayout((l) => ({
...l,
rows: l.rows.map((r, i) => (i === colIdx ? r.map((v, j) => (j === lineIdx ? next : v)) : r)),
}));
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
// Клик без перетаскивания по линии — не двигаем, а убираем её (объединяем куски)
if (!moved) removeRowLine(colIdx, lineIdx);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
// Тап по куску в режиме tag ("door"/"header"/"note") ставит этот тег —
// повторный тап тем же тегом снимает его обратно в нейтральное (excluded).
// Раньше был цикл обычный→мусор→примечание→шапка→обычный на КАЖДЫЙ кусок —
// неудобно тапать по несколько раз; теперь выбираешь режим один раз в
// панели над списком и просто отмечаешь им нужные куски подряд.
const setTag = (ci, ri, tag) => {
const key = `${ci},${ri}`;
const nextExcluded = new Set(excluded);
const nextNotes = new Set(notes);
const nextHeader = new Set(header);
const nextHeaderFields = { ...headerFields };
const current = header.has(key) ? "header" : notes.has(key) ? "note" : excluded.has(key) ? "excluded" : "door";
nextExcluded.delete(key); nextNotes.delete(key); nextHeader.delete(key);
if (current === tag) {
nextExcluded.add(key);
} else if (tag === "note") {
nextNotes.add(key);
} else if (tag === "header") {
nextHeader.add(key);
}
// tag === "door" — уже достаточно того, что кусок ушёл из всех трёх сетов выше
// Шапка иногда автоматически разрезана на 2+ куска (напр. двухуровневая —
// "Марка/Обозначение/.../Приме-" сверху, "поз./0.600/+3.600/+7.200/Всего/..."
// снизу, разделены настоящей линией сетки) — раньше при отметке/снятии
// каждого куска по отдельности detectHeaderFields считал поля только по
// ЕГО СОБСТВЕННЫМ границам и перезаписывал headerFields[ci] последним
// результатом, теряя то, что нашли в других отмеченных кусках той же
// колонки (реальный случай: отмечен только верхний кусок — "Поз."
// распознаётся как "Марка"; только нижний — колонка "Наименование"
// вообще теряется). Если набор кусков-шапки этой колонки изменился (тут
// отметили или сняли) — пересчитываем по ОБЪЕДИНЁННОМУ диапазону всех
// кусков-шапки колонки, что остались, а не по одному последнему клику;
// если не осталось ни одного — стираем устаревший результат.
if (current === "header" || tag === "header") {
const headerRis = [...nextHeader]
.map((k) => k.split(",").map(Number))
.filter(([kc]) => kc === ci)
.map(([, kr]) => kr);
if (headerRis.length) {
const x0 = columns[ci], x1 = columns[ci + 1];
const y0 = Math.min(...headerRis.map((kr) => rows[ci][kr]));
const y1 = Math.max(...headerRis.map((kr) => rows[ci][kr + 1]));
detectHeaderFields(ci, x0, y0, x1, y1);
} else {
delete nextHeaderFields[ci];
}
}
onChange({ ...item, excluded: nextExcluded, notes: nextNotes, header: nextHeader, headerFields: nextHeaderFields });
};
return (
{columns.slice(0, -1).map((_, ci) => {
const x0 = columns[ci] * scale, x1 = columns[ci + 1] * scale;
return rows[ci].slice(0, -1).map((_, ri) => {
const y0 = rows[ci][ri] * scale, y1 = rows[ci][ri + 1] * scale;
const key = `${ci},${ri}`;
const isExcluded = excluded.has(key);
const isNotes = notes.has(key);
const isHeader = header.has(key);
const isDoor = !isExcluded && !isNotes && !isHeader;
const isHeaderBusy = isHeader && headerBusy.has(ci);
const dimmed = hoverKey && hoverKey !== key && isDoor;
const isLayoutMode = mode === "Разметка";
const isLinesMode = mode === "Линии";
const title = isLayoutMode
? "Режим «Разметка» — тап по куску ничего не делает, двигайте линии между кусками"
: isLinesMode
? "Режим «Линии» — клик внутри куска разрежет его в этой точке"
: isDoor
? `Это дверь — нажмите, чтобы снять отметку`
: `Нажмите, чтобы отметить как «${mode}»`;
return (
Двойной клик по изображению — добавить границу строки в этом месте (повторяйте для каждой позиции).
Клик по уже проведённой линии — убрать её и объединить куски обратно.
);
}
function _round50(v) {
return Math.round(v / 50) * 50;
}
function PhotoReviewRow({ row, index, onFeedback }) {
const text = _assembleFieldsText(row.fields);
const g = row.glazing;
return (
{busy
? "Распознавание ещё идёт — уже готовые строки можно проверять прямо сейчас, не дожидаясь конца."
: "Проверьте распознанное построчно — картинка слева, что нашли справа. Отметьте ✓/✕, где явно видно ошибку — это поможет точнее распознавать в следующий раз."}{" "}
Текст ниже не меняется от отметок — если что-то не так, поправьте прямо в поле «Текст заявки» после того как нажмёте «Готово».
)}
{photoSplitBusy && Разбираем PDF на страницы…}
{photoLayoutBusy && Определяем разметку…}
{photoPendingPages.length > 0 && (
<>
{photoPendingPages.some((p) => p.suggested)
? "Похоже, нашли страницы с ведомостью проёмов — отметили их, можно поправить выбор. Клик по странице — выбрать/снять."
: "Отметьте страницы, которые относятся к заявке — на них уйдёт разметка, остальные не учитываются. Клик по странице — выбрать/снять."}
{photoPendingPages.map((p, i) => (
togglePendingPage(i)}
>
{p.page || i + 1}
{p.suggested && похоже на нужную}
{p.keep && ✓}
Так фото будет прочитано по кускам. По умолчанию всё исключено — выберите режим в панели ниже и отмечайте нужные куски: «Дверь» — позиция для расчёта, «Шапка» — строка с подписями столбцов (код сам определит Марку/Обозначение/Кол-во и применит ко всем строкам ниже), «Примечание» — общие указания, не дверь. Повторный клик тем же режимом снимает отметку. «Разметка» — клики по кускам не действуют, только двигайте линии между ними. «Линии» — клик внутри куска режет его в этой точке; клик по самой линии (без перетаскивания) убирает её, объединяя соседние куски. «Сбросить разметку» под фото — начать заново с нуля.
);
}
/* ---------- Тендер: сверка тендерной таблицы с чертежами РД ---------- */
function _diffSpan(text, isDiff) {
return isDiff ? {text} : text;
}
// Вердикт ИИ по уже помеченной подозрительной позиции (см. ai_review.py) —
// бэкенд считает его САМ автоматически в фоне после каждой сверки и кладёт
// прямо в info.ai_review (main.py::_compute_tender_reconcile), кнопки на
// фронте больше не нужны. review === null — ещё не досчитано (см. поллинг в
// TenderCheckPage), review === undefined — сюда вообще не положено (не тот
// статус). Подсказка, не подменяет статус строки.
function TenderAiVerdict({ review, mode }) {
if (review === undefined) return null;
if (review === null) {
return 🤖…;
}
if (mode === "missing") {
return (
🤖 {review.maybe_present ? "❓ возможно, упоминается в тексте" : "похоже, действительно нет"}
{review.note}
второе мнение ИИ, не заменяет проверку человеком
);
}
return (
🤖 {review.verdict === "same" ? "✅ похоже, одна дверь" : review.verdict === "different" ? "⚠ похоже, разные позиции" : "❓ не уверен"}
{review.reasoning}
{(review.readings || []).map((rd, i) => (
{rd.label}: {rd.mark} {rd.h_proem}×{rd.w_proem}
))}
второе мнение ИИ, не заменяет проверку человеком
);
}
// Менеджер визуально проверил позицию и решил, что расхождение/"не найдено"
// на самом деле не проблема (реальный случай: ИИ нашёл "ДВ-7(Л)" в тексте
// для марки "ДВ-7Л" — та же дверь, просто буква зеркальности в скобках) —
// не меняет то, что нашёл код или ИИ, только помечает поверх (main.py::
// tender_project_manual_confirm). Локальный confirmed синхронизируется с
// info.manual_confirm при перезагрузке ведомости (см. эффект ниже), но сразу
// после клика обновляется оптимистично, не дожидаясь следующего опроса.
function TenderManualConfirm({ projectId, mark, sourceLabel, serverConfirmed }) {
const [confirmed, setConfirmed] = usePagesState(serverConfirmed);
const [busy, setBusy] = usePagesState(false);
const [err, setErr] = usePagesState("");
usePagesEffect(() => { setConfirmed(serverConfirmed); }, [serverConfirmed]);
if (!projectId || !mark || !sourceLabel) return null;
const toggle = async () => {
setBusy(true);
setErr("");
try {
const base = `/api/tender-projects/${projectId}/manual-confirm`;
const r = confirmed
? await fetchWithRetry(`${base}?mark=${encodeURIComponent(mark)}&source_label=${encodeURIComponent(sourceLabel)}`, { method: "DELETE" })
: await fetchWithRetry(base, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mark, source_label: sourceLabel }),
});
if (!r.ok) throw new Error(await r.text());
setConfirmed(!confirmed);
} catch (e) {
setErr("не сохранилось: " + e.message);
} finally {
setBusy(false);
}
};
return (
{err && {err}}
);
}
// Для not_found — менеджер хочет своими глазами увидеть, на что сослался ИИ
// ("возможно, упоминается в тексте"), не открывая весь лист. Ищет саму
// марку на странице (main.py::tender_project_not_found_shot), не зависит от
// точной формулировки ИИ.
function TenderNotFoundShot({ projectId, filename, page, mark }) {
const [result, setResult] = usePagesState(null);
const [busy, setBusy] = usePagesState(false);
const [zoomed, setZoomed] = usePagesState(false);
const [err, setErr] = usePagesState("");
if (!projectId || !filename || !page || !mark) return null;
const handleClick = async () => {
if (result) {
if (result.found) setZoomed(true);
return;
}
setBusy(true);
setErr("");
try {
const url = `/api/tender-projects/${projectId}/not-found-shot?filename=${encodeURIComponent(filename)}&page=${page}&mark=${encodeURIComponent(mark)}`;
const r = await fetchWithRetry(url, { method: "GET" });
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
setResult(data);
if (data.found) setZoomed(true);
} catch (e) {
setErr("не загрузилось: " + e.message);
} finally {
setBusy(false);
}
};
return (
{err && {err}}
{result && !result.found && не нашёл в тексте страницы}
{zoomed && result && result.found && setZoomed(false)} />}
);
}
// Одна ячейка ведомости — что говорит ОДИН источник по этой позиции (см.
// форму 7/8 ГОСТ 21.1101: колонка "Обозначение" — на основании чего
// записана позиция). Расхождение уже посчитано на бэкенде (см.
// tender_reconcile._diff_fields) — здесь только показываем, какие поля
// именно разошлись, не утверждая, что это другая дверь (могла быть просто
// опечатка в одном из документов).
function TenderSourceCell({ info, projectId, mark, sourceLabel }) {
if (info.status === "not_found") {
return (
не найдено
{info.source_file && (
)}
);
}
if (info.status === "ambiguous") {
return (
неоднозначно ({info.candidates.length})
);
}
if (info.status === "mismatch") {
return (
);
}
// Одна строка ведомости = одна позиция (марка). Колонки — как в форме 7/8
// ГОСТ 21.1101 (Поз./Наименование/Кол-во) плюс по одной колонке на каждый
// отмеченный источник сверки (принцип сопоставительной ведомости объёмов
// работ — см. Приложение №12 методики определения сметной стоимости:
// каждое значение должно быть прослеживаемо до конкретного документа).
function TenderVedomostRow({ r, index, sourceLabels, projectId }) {
const hasPrimaryConflict = !!(r.primary_conflicts && r.primary_conflicts.length);
const warn = r.size_matches === false || r.sources_disagree || r.has_mismatch || hasPrimaryConflict;
return (
);
}
function _sheetKey(filename, sheet) {
return filename + "|||" + sheet;
}
// Из общего Set выбранных ключей (см. _pageKey/_sheetKey) достаёт только те,
// что относятся к ОДНОМУ конкретному связующему файлу — нужно, когда
// связующих файлов несколько (см. tender_reconcile.merge_primary) и на шаге
// сверки каждому файлу нужен свой список выбранных листов/страниц отдельно.
function _selectedRawForFile(selectedSet, filename, kind) {
const prefix = filename + "|||";
const out = [];
for (const key of selectedSet) {
if (key.startsWith(prefix)) {
const raw = key.slice(prefix.length);
out.push(kind === "sheet" ? raw : Number(raw));
}
}
return out;
}
function TenderLinkFilePicker({ linkFile, selected, onToggle }) {
return (
Любой файл, где для каждой марки есть размер — единственное, от чего можно оттолкнуться (в тендерной
таблице марок нет). Добавляйте по одному — не нужно собирать все сразу, список можно пополнять в течение
всей работы над тендером.
Формат — как у сопоставительной ведомости объёмов работ (Приложение №12 методики определения сметной
стоимости) и спецификации по ГОСТ 21.1101 (форма 7/8: поз./наименование/кол-во): каждая марка ({report.primary_count})
и что о ней говорит каждый отмеченный источник, с прослеживаемостью до конкретного листа документа.
Обновляется автоматически при любом изменении файлов или выбора.