// ferro-pages.jsx — Заявки list + Новый расчёт form
const { useState: usePagesState, useEffect: usePagesEffect } = React;

// poll until request status leaves "processing"
async function waitReady(reqId) {
  for (let i = 0; i < 90; i++) {
    await new Promise((r) => setTimeout(r, 1000));
    const data = await apiFetch("GET", `/requests/${reqId}`);
    if (data.status !== "processing" && data.status !== "new") return data;
  }
  throw new Error("Превышено время ожидания");
}

/* ---------- Заявки ---------- */

function ZCards({ rows, onOpen, onDelete }) {
  return (
    <div className="zcards">
      {rows.map((z) => (
        <div key={z.id} className="zcard" onClick={() => onOpen(z.id)}>
          <div className="zcard__top">
            <span className="zcard__num">№{z.id}</span>
            <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
              <span className={"pill" + (
                (z.status === "Ошибка" || z.status === "error") ? " pill--error" :
                (z.status !== "Готово" && z.status !== "Отправлено") ? " pill--work" : ""
              )}>
                {z.status}
              </span>
              {z.calcVerified === true  && <span className="pill pill--ok">Верный</span>}
              {z.calcVerified === false && <span className="pill pill--err">Ошибки</span>}
              <button
                className="zdel"
                title="Удалить заявку"
                onClick={(e) => { e.stopPropagation(); onDelete(z.id); }}
              />
            </div>
          </div>
          <div className="zcard__meta">
            {z.source} · {z.positions} поз. · {z.date}
          </div>
          {z.email && <div className="zcard__email">{z.email}</div>}
          <div className="zcard__sum">{RUB(z.sum)}</div>
        </div>
      ))}
    </div>
  );
}

function ZayavkiPage({ onOpen, onNew }) {
  const [requests, setRequests] = usePagesState([]);
  const [loading, setLoading] = usePagesState(true);
  const [emailQuery, setEmailQuery] = usePagesState("");

  const loadRequests = (email) => {
    setLoading(true);
    const path = email ? `/requests?email=${encodeURIComponent(email)}` : "/requests";
    apiFetch("GET", path)
      .then((data) => { setRequests(data); setLoading(false); })
      .catch(() => setLoading(false));
  };

  const isFirstRender = React.useRef(true);
  usePagesEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      loadRequests();
      return;
    }
    // Debounce — не дёргать сервер на каждое нажатие клавиши при вводе email.
    const t = setTimeout(() => loadRequests(emailQuery.trim()), 350);
    return () => clearTimeout(t);
  }, [emailQuery]);

  const handleDelete = async (id) => {
    try {
      await apiFetch("DELETE", `/requests/${id}`);
      setRequests((rs) => rs.filter((r) => r.id !== id));
    } catch (e) {
      console.error("delete error", e);
    }
  };

  const rows = requests.map((r) => ({
    id: r.id,
    source: r.source === "email" ? "Email" : "Ручной ввод",
    positions: r.pos_count || 0,
    sum: r.grand_total || 0,
    status: statusLabel(r.status),
    date: fmtDate(r.created_at),
    calcVerified: r.calc_verified === 1 ? true : r.calc_verified === 0 ? false : null,
    email: r.from_email || "",
  }));

  return (
    <div className="page" data-screen-label="Заявки">
      <div className="pagehead">
        <h1 className="ttl">Заявки</h1>
        <button className="btn btn--solid" onClick={onNew}>＋ Новый расчёт</button>
      </div>
      <div className="zsearch">
        <input
          type="text"
          className="zsearch__input"
          placeholder="Поиск по email клиента…"
          value={emailQuery}
          onChange={(e) => setEmailQuery(e.target.value)}
        />
      </div>
      {loading ? (
        <div style={{ padding: "40px 0", color: "var(--muted)" }}>Загрузка…</div>
      ) : rows.length === 0 ? (
        <div style={{ padding: "40px 0", color: "var(--muted)" }}>
          {emailQuery.trim() ? "Ничего не найдено" : "Заявок пока нет"}
        </div>
      ) : (
        <ZCards rows={rows} onOpen={onOpen} onDelete={handleDelete} />
      )}
    </div>
  );
}

/* ---------- Новый расчёт ---------- */

function Seg({ options, value, onChange }) {
  return (
    <div className="seg">
      {options.map((o) => (
        <button
          key={o}
          className={"seg__btn" + (o === value ? " seg__btn--on" : "")}
          onClick={() => onChange(o)}
        >
          {o}
        </button>
      ))}
    </div>
  );
}

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 (
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="11" cy="11" r="7" />
      <path d="M21 21l-4.35-4.35" />
      <path d="M8 11h6" />
      {!zoomed && <path d="M11 8v6" />}
    </svg>
  );
}

// Все ключи "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 (
    <div className="photoSplit">
      <div className="photoSplit__canvas" style={{ width: dispW, height: dispH }}>
        <img src={item.previewUrl} alt="" style={{ width: dispW, height: dispH }} draggable={false} />
        <button
          type="button"
          className="photoSplit__zoom"
          onClick={() => setZoomed((z) => !z)}
          title={zoomed ? "Уменьшить" : "Увеличить"}
        >
          <ZoomIcon zoomed={zoomed} />
        </button>
        {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 (
              <div
                key={key}
                className={"photoSplit__piece" + (isDoor ? " photoSplit__piece--door" : "") + (isNotes ? " photoSplit__piece--notes" : "") + (isHeader ? " photoSplit__piece--header" : "") + (dimmed ? " photoSplit__piece--dim" : "")}
                style={{ left: x0, top: y0, width: x1 - x0, height: y1 - y0 }}
                onMouseEnter={() => setHoverKey(key)}
                onMouseLeave={() => setHoverKey(null)}
                onClick={(e) => {
                  if (isLayoutMode) return;
                  if (isLinesMode) {
                    const rect = e.currentTarget.getBoundingClientRect();
                    splitPiece(ci, ri, (e.clientY - rect.top) / scale + rows[ci][ri]);
                    return;
                  }
                  setTag(ci, ri, _TAG_MODE_TO_KEY[mode]);
                }}
                onDoubleClick={(e) => {
                  const rect = e.currentTarget.getBoundingClientRect();
                  splitPiece(ci, ri, (e.clientY - rect.top) / scale + rows[ci][ri]);
                }}
                title={title + " · двойной клик — разрезать здесь"}
              >
                {isDoor && <span className="photoSplit__piece-x">Д</span>}
                {isNotes && <span className="photoSplit__piece-x">П</span>}
                {isHeader && <span className="photoSplit__piece-x">{isHeaderBusy ? "…" : "Ш"}</span>}
                {isHeader && !isHeaderBusy && headerFields[ci] && (
                  <div className="photoSplit__headerFields">
                    {headerFields[ci].map((f, fi) => (
                      <div
                        key={fi}
                        className="photoSplit__headerField"
                        style={{ left: (f.x0 - columns[ci]) * scale, width: (f.x1 - f.x0) * scale }}
                        title={f.label}
                      >
                        {_HEADER_FIELD_RU[f.field] || f.field}
                      </div>
                    ))}
                  </div>
                )}
              </div>
            );
          });
        })}
        {columns.slice(1, -1).map((x, i) => (
          <div
            key={`col-${i}`}
            className="photoSplit__line photoSplit__line--col"
            style={{ left: x * scale - 4 }}
            onMouseDown={startDragCol(i + 1)}
            title="Перетащите, если колонка разрезана не там"
          />
        ))}
        {columns.slice(0, -1).map((_, ci) => {
          const x0 = columns[ci] * scale, x1 = columns[ci + 1] * scale;
          return rows[ci].slice(1, -1).map((y, li) => (
            <div
              key={`row-${ci}-${li}`}
              className="photoSplit__line photoSplit__line--row"
              style={{ left: x0, width: x1 - x0, top: y * scale - 4 }}
              onMouseDown={startDragRow(ci, li + 1)}
              title="Перетащите — подвинуть; клик без перетаскивания — убрать линию (объединить куски)"
            />
          ));
        })}
      </div>
      <button type="button" className="btn btn--ghost photoSplit__reset" onClick={resetSplit}>
        Сбросить разметку этого куска
      </button>
      <div className="photoSplit__hint">
        Двойной клик по изображению — добавить границу строки в этом месте (повторяйте для каждой позиции).
        Клик по уже проведённой линии — убрать её и объединить куски обратно.
      </div>
    </div>
  );
}

function _round50(v) {
  return Math.round(v / 50) * 50;
}

function PhotoReviewRow({ row, index, onFeedback }) {
  const text = _assembleFieldsText(row.fields);
  const g = row.glazing;
  return (
    <div className="photoReview__row">
      <img className="photoReview__img" src={`data:image/png;base64,${row.image_b64}`} alt="" />
      <div className="photoReview__data">
        <div className="photoReview__block">
          <div className="photoReview__text">{text || <i>(пусто)</i>}</div>
        </div>
        {row.sketch_b64 && (
          <div className="photoReview__block photoReview__block--sketch">
            <div className="photoReview__sketchLabel">Найден эскиз этой позиции (отдельно на листе):</div>
            <img className="photoReview__sketchImg" src={`data:image/png;base64,${row.sketch_b64}`} alt="Эскиз позиции" />
          </div>
        )}
        {g && (
          <div className="photoReview__block photoReview__block--glazing">
            <div className="photoReview__text">
              Эскиз: {g.door_type === "double" ? "двупольная" : "однопольная"} дверь{" "}
              <span className="photoReview__conf">(уверенность: {Math.round(g.door_confidence * 100)}%)</span>
              {g.panes.length > 0 && (
                <>
                  , {g.panes.length === 1 ? "стекло" : `стёкол: ${g.panes.length}`}{" "}
                  {g.panes.map((p, i) => (
                    <span key={i}>
                      {i > 0 && ", "}{_round50(p.width_mm)}×{_round50(p.height_mm)} мм{" "}
                      <span className="photoReview__conf">
                        (найдено ~{p.width_mm}×{p.height_mm}, {Math.round(p.confidence * 100)}%)
                      </span>
                    </span>
                  ))}
                  {g.incomplete && (
                    <span className="photoReview__warn"> — на двупольной двери нашли только одно стекло, возможно пропустили второе</span>
                  )}
                </>
              )}
            </div>
            {g.transom && (
              <div className="photoReview__text photoReview__warn">
                ⚠ Фрамуга на чертеже: {_round50(g.transom.width_mm)}×{_round50(g.transom.height_mm)} мм,{" "}
                {g.transom.glazed ? "остеклённая" : "глухая"}{" "}
                <span className="photoReview__conf">
                  (по масштабу, найдено ~{g.transom.width_mm}×{g.transom.height_mm}, {Math.round(g.transom.confidence * 100)}%)
                </span>{" "}
                — нет в тексте заявки, требуется подтверждение размера
              </div>
            )}
            <div className="photoReview__actions">
              <button
                type="button"
                className={"photoReview__fb" + (row.glazingFeedback === true ? " photoReview__fb--on-yes" : "")}
                onClick={() => onFeedback(index, true)}
                title="Размер стекла определён верно"
              >
                ✓ Верно
              </button>
              <button
                type="button"
                className={"photoReview__fb" + (row.glazingFeedback === false ? " photoReview__fb--on-no" : "")}
                onClick={() => onFeedback(index, false)}
                title="Размер стекла определён неверно"
              >
                ✕ Неверно
              </button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

function PhotoReviewScreen({ rows, onFeedback, onDone, busy, progress }) {
  return (
    <div className="photoReview">
      <div className="photoSplit__hint">
        {busy
          ? "Распознавание ещё идёт — уже готовые строки можно проверять прямо сейчас, не дожидаясь конца."
          : "Проверьте распознанное построчно — картинка слева, что нашли справа. Отметьте ✓/✕, где явно видно ошибку — это поможет точнее распознавать в следующий раз."}{" "}
        Текст ниже не меняется от отметок — если что-то не так, поправьте прямо в поле «Текст заявки» после того как нажмёте «Готово».
      </div>
      {busy && (
        <div className="photoUpload__progress">
          {progress && progress.total > 0 ? (
            <>
              <div className="photoUpload__progressBar">
                <div className="photoUpload__progressFill" style={{ width: `${Math.round((progress.done / progress.total) * 100)}%` }} />
              </div>
              <span className="photoUpload__status">Распознано {progress.done} из {progress.total}…</span>
            </>
          ) : (
            <span className="photoUpload__status">Готовим распознавание…</span>
          )}
        </div>
      )}
      <div className="photoReview__list">
        {rows.map((row, i) => (
          <PhotoReviewRow key={i} row={row} index={i} onFeedback={onFeedback} />
        ))}
      </div>
      <button type="button" className="btn btn--ghost" onClick={onDone} disabled={busy}>
        {busy ? "Ещё распознаём…" : "Готово — перейти к тексту заявки"}
      </button>
    </div>
  );
}

function NewCalcPage({ onCalc, text, onTextChange, onReconcile }) {
  const [email, setEmail] = usePagesState("");
  const [tip, setTip] = usePagesState("Розница");
  const [fmt, setFmt] = usePagesState("Текст");
  const [mont, setMont] = usePagesState("Да");
  const [qtyTier, setQtyTier] = usePagesState("Авто");
  const [delivery, setDelivery] = usePagesState("По СПб");
  const setText = onTextChange;
  const [originalText, setOriginalText] = usePagesState("");
  const [note, setNote] = usePagesState("");
  const [busy, setBusy] = usePagesState(false);
  const [normBusy, setNormBusy] = usePagesState(false);
  const [photoItems, setPhotoItems] = usePagesState([]);
  const [photoTagMode, setPhotoTagMode] = usePagesState("Дверь");
  const [photoLayoutBusy, setPhotoLayoutBusy] = usePagesState(false);
  const [photoPendingPages, setPhotoPendingPages] = usePagesState([]);
  const [photoSplitBusy, setPhotoSplitBusy] = usePagesState(false);
  // Раньше было 4 вкладки формата (Текст/Таблица/PDF/Фото) — менеджеру нужно
  // было самому понять, что перед ним, прежде чем грузить файл; путало (см.
  // историю). Теперь только "Текст"/"Файл" — тип файла определяет код сам,
  // по расширению. photoFallbackActive — не переключение вкладки, а флаг
  // "сейчас идёт распознавание по фото/OCR" (скан-PDF без текстового слоя,
  // фото, или ссылка, которая пришла картинкой) — показываем тот же экран
  // отбора страниц/разметки прямо на вкладке "Файл", ничего никуда не дёргая.
  const [photoFallbackActive, setPhotoFallbackActive] = usePagesState(false);
  // Чем оказался успешно прочитанный файл — влияет на input_type при отправке
  // заявки (какой LLM-промпт парсить, см. parser.parse_text): 'table' | 'pdf' | null.
  const [fileKind, setFileKind] = usePagesState(null);
  const [photoBusy, setPhotoBusy] = usePagesState(false);
  const [photoProgress, setPhotoProgress] = usePagesState(null);
  const [photoRows, setPhotoRows] = usePagesState([]);
  const [photoFinalText, setPhotoFinalText] = usePagesState(null);
  const [photoReviewing, setPhotoReviewing] = usePagesState(false);
  const [photoPendingCount, setPhotoPendingCount] = usePagesState(0);
  const photoRevealQueueRef = React.useRef([]);
  const photoRevealTimerRef = React.useRef(null);
  const [fileBusy, setFileBusy] = usePagesState(false);
  const [fileName, setFileName] = usePagesState("");
  const [dragOver, setDragOver] = usePagesState(false);
  // Предупреждения от детерминированного разбора табличной заявки по шапке
  // (пропущенные нераспознанные строки + находки AI-перепроверки) — НЕ часть
  // текста заявки (текст должен остаться чистым для расчёта), показываются
  // менеджеру отдельным блоком рядом со статусом загрузки.
  const [tableWarnings, setTableWarnings] = usePagesState([]);
  const [errMsg, setErrMsg] = usePagesState("");
  const [statusMsg, setStatusMsg] = usePagesState("");

  // Сверка с КП менеджера — берёт заявку прямо из этой формы (text/fileKind,
  // как есть, тем же способом, что и обычный расчёт), просит только КП
  // менеджера (файл/ссылка) в попапе поверх формы.
  const [showReconcile, setShowReconcile] = usePagesState(false);
  const [kpFile, setKpFile] = usePagesState(null);
  const [kpLink, setKpLink] = usePagesState("");
  const [kpDragOver, setKpDragOver] = usePagesState(false);
  const [kpBusy, setKpBusy] = usePagesState(false);
  const [kpErr, setKpErr] = usePagesState("");

  const handleNorm = async () => {
    if (!text.trim()) return;
    setNormBusy(true);
    setErrMsg("");
    try {
      if (!originalText) setOriginalText(text);
      const res = await apiFetch("POST", "/normalize-text", { text });
      setText(res.notes ? res.text + "\n\n" + res.notes : res.text);
    } catch (e) {
      setErrMsg("Ошибка нормализации: " + e.message);
    } finally {
      setNormBusy(false);
    }
  };

  const expandPdfToImages = async (file) => {
    const token = await uploadFileChunked(file);
    const fd = new FormData();
    fd.append("file_token", token);
    const r = await fetchWithRetry("/api/pdf-to-images", { method: "POST", body: fd });
    if (!r.ok) throw new Error(await r.text());
    const res = await r.json();
    const base = file.name.replace(/\.pdf$/i, "");
    const suggestedSet = new Set(res.suggested || []);
    return res.images.map((b64, i) => {
      const bin = atob(b64);
      const bytes = new Uint8Array(bin.length);
      for (let j = 0; j < bin.length; j++) bytes[j] = bin.charCodeAt(j);
      const pageNo = (res.pages && res.pages[i]) || i + 1;
      const file = new File([bytes], `${base}_стр${pageNo}.png`, { type: "image/png" });
      return { file, page: pageNo, suggested: suggestedSet.has(pageNo), sketches: (res.sketches && res.sketches[i]) || [] };
    });
  };

  // Файлы (в т.ч. страницы, на которые распались PDF) сначала попадают в
  // «отборочный» экран — можно убрать лишние страницы (титульный лист,
  // не относящееся к заявке и т.п.) до того, как на каждую уйдёт запрос
  // разметки (/api/photo-layout — не бесплатно и не мгновенно).
  const handlePhotoSelect = async (fileList) => {
    const rawFiles = Array.from(fileList || []);
    if (!rawFiles.length) return;
    setPhotoSplitBusy(true);
    setErrMsg("");
    try {
      const entries = [];
      for (const f of rawFiles) {
        if (f.type === "application/pdf" || /\.pdf$/i.test(f.name)) {
          entries.push(...await expandPdfToImages(f));
        } else {
          entries.push({ file: f, page: null, suggested: false, sketches: [] });
        }
      }
      setPhotoPendingPages(entries.map((e) => ({
        file: e.file,
        previewUrl: URL.createObjectURL(e.file),
        page: e.page,
        suggested: e.suggested,
        keep: e.suggested,
        sketches: e.sketches || [],
      })));
    } catch (e) {
      setErrMsg("Ошибка обработки фото: " + e.message);
    } finally {
      setPhotoSplitBusy(false);
    }
  };

  const togglePendingPage = (idx) => {
    setPhotoPendingPages((pages) => pages.map((p, i) => (i === idx ? { ...p, keep: !p.keep } : p)));
  };

  const handlePhotoPagesCancel = () => {
    setPhotoPendingPages([]);
  };

  const handlePhotoPagesConfirm = async () => {
    const kept = photoPendingPages.filter((p) => p.keep);
    if (!kept.length) return;
    setPhotoPendingPages([]);
    setPhotoLayoutBusy(true);
    setErrMsg("");
    try {
      const items = [];
      for (const p of kept) {
        // p.token — страница уже лежит на сервере (пришла по ссылке с Яндекс.Диска),
        // повторно заливать не нужно. Иначе — обычный локальный файл.
        const token = p.token || await uploadFileChunked(p.file);
        const fd = new FormData();
        fd.append("file_token", token);
        const r = await fetchWithRetry("/api/photo-layout", { method: "POST", body: fd });
        if (!r.ok) throw new Error(await r.text());
        const layout = await r.json();
        items.push({
          file: p.file,
          token: p.file ? undefined : token,
          previewUrl: p.previewUrl,
          // По умолчанию ВСЕ куски исключены (нейтральны) — менеджер явно
          // отмечает нужные (см. _allPieceKeys): "отметить нужные двери, а
          // не убирать лишнее" логичнее, когда на листе много посторонних
          // кусков (заголовки разделов, штамп, пустые поля).
          layout, excluded: _allPieceKeys(layout), notes: new Set(), header: new Set(), headerFields: {},
          // Эскизы, найденные заранее по векторным линиям PDF (см. p.sketches
          // из /api/upload-link или /api/pdf-to-images) — сервер сопоставит их
          // со строкой при распознавании, см. handlePhotoRecognize.
          sketches: p.sketches || [],
        });
      }
      setPhotoItems(items);
    } catch (e) {
      setErrMsg("Ошибка обработки фото: " + e.message);
    } finally {
      setPhotoLayoutBusy(false);
    }
  };

  const updatePhotoItem = (idx, next) => {
    setPhotoItems((items) => items.map((it, i) => (i === idx ? next : it)));
  };

  // Строки прилетают с сервера быстрее, чем менеджер успевает их читать (после
  // перехода на 1 запрос на строку вместо 7 на поле) — специально придерживаем
  // показ каждой следующей строки, чтобы за распознаванием реально можно было
  // успевать следить, а не только постфактум. Первая строка показывается сразу,
  // как только готова, дальше — по одной раз в REVEAL_DELAY_MS.
  const REVEAL_DELAY_MS = 3000;

  const scheduleReveal = () => {
    if (photoRevealTimerRef.current) return;
    const tick = () => {
      const queue = photoRevealQueueRef.current;
      if (queue.length === 0) {
        photoRevealTimerRef.current = null;
        return;
      }
      const next = queue.shift();
      setPhotoRows((prev) => [...prev, next]);
      setPhotoPendingCount(queue.length);
      photoRevealTimerRef.current = setTimeout(tick, REVEAL_DELAY_MS);
    };
    tick();
  };

  // Общий разбор ответа-потока NDJSON (строка = один JSON-объект): {row:{...}}
  // по каждой обработанной строке (для построчного экрана проверки),
  // {progress,total} для индикатора, в конце {done,text} или {error}.
  // Используется и обычной загрузкой файла, и загрузкой по ссылке с Яндекс.Диска.
  const consumePhotoStream = async (r) => {
    if (!r.ok) throw new Error(await r.text());
    const reader = r.body.getReader();
    const decoder = new TextDecoder();
    let buf = "";
    let finalText = null;
    let streamError = null;
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      let nl;
      while ((nl = buf.indexOf("\n")) >= 0) {
        const line = buf.slice(0, nl).trim();
        buf = buf.slice(nl + 1);
        if (!line) continue;
        const evt = JSON.parse(line);
        if (evt.error) streamError = evt.error;
        else if (evt.done) finalText = evt.text;
        else if (evt.row) {
          photoRevealQueueRef.current.push({ ...evt.row, glazingFeedback: null });
          setPhotoPendingCount(photoRevealQueueRef.current.length);
          scheduleReveal();
        } else setPhotoProgress({ done: evt.progress, total: evt.total });
      }
    }
    if (streamError) throw new Error(streamError);
    if (finalText == null) throw new Error("Пустой ответ сервера");
    return finalText;
  };

  const resetPhotoRecognizeState = () => {
    setPhotoBusy(true);
    setErrMsg("");
    setPhotoProgress(null);
    setPhotoRows([]);
    setPhotoFinalText(null);
    setPhotoPendingCount(0);
    if (photoRevealTimerRef.current) { clearTimeout(photoRevealTimerRef.current); photoRevealTimerRef.current = null; }
    photoRevealQueueRef.current = [];
  };

  const handlePhotoRecognize = async () => {
    if (!photoItems.length) return;
    resetPhotoRecognizeState();
    try {
      // Грузим по одному файлу за раз (не Promise.all) — несколько параллельных
      // долгих передач на нестабильном канале только повышают шанс словить
      // обрыв сразу в нескольких местах одновременно. Если фото пришло по
      // ссылке (item.token) — оно уже лежит на сервере, заливать заново не надо.
      const tokens = [];
      for (const it of photoItems) tokens.push(it.token || await uploadFileChunked(it.file));
      const fd = new FormData();
      fd.append("file_tokens", JSON.stringify(tokens));
      const layouts = photoItems.map((it) => ({
        ...it.layout,
        excluded: Array.from(it.excluded).map((k) => k.split(",").map(Number)),
        notes: Array.from(it.notes).map((k) => k.split(",").map(Number)),
        header: Array.from(it.header || []).map((k) => k.split(",").map(Number)),
        headerFields: it.headerFields || {},
        // Эскизы, найденные заранее по векторным линиям PDF (см.
        // main._find_page_sketches) — сервер сопоставит их со строками по
        // полю "Поз."/"Марка" (см. normalize_photo_stream в parser.py).
        sketches: it.sketches || [],
      }));
      fd.append("layouts", JSON.stringify(layouts));
      // Ретрай только на уровне самого fetch() (обрыв до установки соединения) —
      // поток NDJSON после начала передачи здесь намеренно не перезапускаем
      // (уже показанные строки пришлось бы откатывать, это отдельная задача).
      const r = await fetchWithRetry("/api/normalize-photo", { method: "POST", body: fd });
      const finalText = await consumePhotoStream(r);
      setPhotoFinalText(finalText);
      setPhotoReviewing(true);
    } catch (e) {
      setErrMsg("Ошибка распознавания фото: " + e.message);
    } finally {
      setPhotoBusy(false);
      setPhotoProgress(null);
    }
  };

  // Обходной путь для тех, у кого стабильно рвётся прямая загрузка файла —
  // файл идёт с её устройства на Яндекс.Диск (это не режется), сервер сам
  // скачивает его оттуда и кладёт под токеном у себя, её соединение до нас в
  // передаче файла не участвует вообще. Одна ручка на все типы файлов (см.
  // /api/upload-link в main.py) — сервер сам решает, таблица это, PDF с
  // текстом или скан/фото, ровно как и handleUnifiedFileSelect для локальной
  // загрузки, только тип файла узнаём уже после скачивания.
  const [fileLink, setFileLink] = usePagesState("");
  const [fileLinkBusy, setFileLinkBusy] = usePagesState(false);
  const handleUnifiedLink = async () => {
    const link = fileLink.trim();
    if (!link) return;
    setFileLinkBusy(true);
    setErrMsg("");
    try {
      const r = await fetchWithRetry("/api/upload-link", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ url: link }),
      });
      if (!r.ok) throw new Error(await r.text());
      const res = await r.json();
      setFileLink("");
      if (res.type === "text") {
        setFileName(res.filename);
        setFileKind(res.kind);
        setTableWarnings(res.warnings || []);
        setPhotoFallbackActive(false);
        setText(res.text);
      } else {
        setFileName("");
        setFileKind(null);
        setTableWarnings([]);
        setPhotoFallbackActive(true);
        setPhotoPendingPages(res.items.map((it) => ({
          token: it.token,
          previewUrl: "data:image/png;base64," + it.image_b64,
          page: it.page,
          suggested: !!it.suggested,
          keep: !!it.suggested,
          sketches: it.sketches || [],
        })));
      }
    } catch (e) {
      setErrMsg("Ошибка обработки файла по ссылке: " + e.message);
    } finally {
      setFileLinkBusy(false);
    }
  };

  // Подтверждение верно/неверно нужно только для находки по эскизу — OCR-текст
  // подтверждать бесполезно, поправить его подтверждением всё равно нельзя,
  // в отличие от эскиза (эти отметки копятся для дообучения модели Roboflow).
  const handlePhotoFeedback = async (rowIndex, confirmed) => {
    const row = photoRows[rowIndex];
    if (!row || !row.glazing) return;
    setPhotoRows((prev) =>
      prev.map((r, i) => (i === rowIndex ? { ...r, glazingFeedback: confirmed } : r))
    );
    try {
      await apiFetch("POST", "/photo-feedback", {
        kind: "glazing",
        field: null,
        value: row.glazing.panes.map((p) => `${p.width_mm}x${p.height_mm}`).join(", "),
        confirmed,
        image_b64: row.image_b64,
        scheme_b64: row.scheme_b64 || null,
      });
    } catch (e) {
      console.error("photo-feedback", e);
    }
  };

  const handlePhotoReviewDone = () => {
    setText(photoFinalText);
    setPhotoReviewing(false);
    setPhotoRows([]);
    setPhotoFinalText(null);
  };

  const submitFileToken = async (token, filename, endpoint) => {
    const fd = new FormData();
    fd.append("file_token", token);
    fd.append("filename", filename);
    const r = await fetchWithRetry(endpoint, { method: "POST", body: fd });
    if (!r.ok) throw new Error(await r.text());
    const res = await r.json();
    setText(res.text);
    return res;
  };

  const handleFileUpload = async (file, endpoint) => {
    if (!file) return;
    setFileBusy(true);
    setErrMsg("");
    setFileName(file.name);
    setTableWarnings([]);
    try {
      const token = await uploadFileChunked(file);
      const res = await submitFileToken(token, file.name, endpoint);
      // res.kind — только у /api/normalize-table (см. main.py::_normalize_table_smart):
      // 'presplit', если таблицу удалось детерминированно прочитать по шапке
      // (столбцы по номеру, не угадывание LLM), иначе как раньше 'table'.
      // У /api/normalize-pdf такого поля нет — используем endpoint как раньше.
      setFileKind(res.kind || (endpoint === "/api/normalize-table" ? "table" : "pdf"));
      setTableWarnings(res.warnings || []);
      setPhotoFallbackActive(false);
    } catch (e) {
      // Обычный человек грузит сюда любой PDF, не разбирая заранее скан это
      // или нет — а причин, по которым чтение PDF как текста не удалось, две:
      // нет текстового слоя (см. normalize_pdf_file в parser.py — формулировка
      // "похоже, это скан...") ИЛИ слишком много текста для одного запроса
      // де-колонизации (реальный случай — большой архитектурный чертёж на
      // много страниц упирается в лимит токенов/минуту у OpenAI). Средство
      // одно и то же в обоих случаях — не заставляем самого разбираться,
      // продолжаем тем же файлом через отбор страниц + распознавание по фото.
      if (endpoint === "/api/normalize-pdf") {
        setFileName("");
        setFileKind(null);
        setPhotoFallbackActive(true);
        await handlePhotoSelect([file]);
      } else {
        setErrMsg("Ошибка чтения файла: " + e.message);
      }
    } finally {
      setFileBusy(false);
    }
  };

  // Единая точка входа для локальной загрузки на вкладке "Файл" — тип файла
  // определяем сами по расширению, чтобы не заставлять менеджера выбирать
  // формат (было 4 вкладки: Текст/Таблица/PDF/Фото — путало, см. историю).
  // Несколько файлов или файл без распознанного табличного/PDF-расширения —
  // считаем фото-заявкой (страницы/сканы), единственный xlsx/csv/pdf —
  // читаем как таблицу/текст соответствующей ручкой.
  const handleUnifiedFileSelect = async (fileList) => {
    const files = Array.from(fileList || []);
    if (!files.length) return;
    const first = files[0];
    const name = (first.name || "").toLowerCase();
    const isTable = /\.(xlsx|xls|csv)$/.test(name);
    const isPdf = first.type === "application/pdf" || /\.pdf$/.test(name);
    if (files.length === 1 && isTable) {
      await handleFileUpload(first, "/api/normalize-table");
    } else if (files.length === 1 && isPdf) {
      await handleFileUpload(first, "/api/normalize-pdf");
    } else {
      setFileKind(null);
      setPhotoFallbackActive(true);
      await handlePhotoSelect(files);
    }
  };

  const handleCalc = async () => {
    if (!text.trim()) return;
    setBusy(true);
    setErrMsg("");
    setStatusMsg("Отправляем заявку…");
    try {
      const tierMap = { "Розница": 1, "от 5 шт": 5, "от 10 шт": 10, "от 50 шт": 50, "от 100 шт": 100 };
      const res = await apiFetch("POST", "/requests", {
        from_email: email || "",
        from_name: "",
        mode: tip === "Тендер" ? "tender" : "retail",
        input_type: photoFallbackActive || fileKind === "presplit" ? "presplit" : fileKind === "table" ? "table" : fileKind === "pdf" ? "pdf" : "text",
        include_install: mont !== "Нет",
        price_qty_override: tierMap[qtyTier] || null,
        delivery_zone: delivery,
        text,
        text_original: originalText || null,
        notes: note,
      });
      setStatusMsg("");
      await waitReady(res.id);
      onCalc(res.id);
    } catch (e) {
      setErrMsg("Ошибка: " + e.message);
      setBusy(false);
      setStatusMsg("");
    }
  };

  const calcInputType = () =>
    photoFallbackActive || fileKind === "presplit" ? "presplit" : fileKind === "table" ? "table" : fileKind === "pdf" ? "pdf" : "text";

  const handleReconcile = async () => {
    if (!kpFile && !kpLink.trim()) return;
    setKpBusy(true);
    setKpErr("");
    try {
      const fd = new FormData();
      fd.append("zayavka_text", text);
      fd.append("input_type", calcInputType());
      fd.append("mode", tip === "Тендер" ? "tender" : "retail");
      if (kpFile) fd.append("kp", kpFile);
      else fd.append("kp_link", kpLink.trim());
      const r = await fetchWithRetry("/api/reconcile-kp", { method: "POST", body: fd });
      if (!r.ok) throw new Error(await r.text());
      const data = await r.json();
      setShowReconcile(false);
      setKpFile(null);
      setKpLink("");
      onReconcile(data);
    } catch (e) {
      setKpErr("Ошибка сверки: " + e.message);
    } finally {
      setKpBusy(false);
    }
  };

  return (
    <div className="page" data-screen-label="Новый расчёт">
      <div className="pagehead">
        <h1 className="ttl">Новый расчёт</h1>
      </div>

      <div className="form">
        <div className="form__row">
          <label className="ffield form__email">
            <span>Email клиента</span>
            <input
              className="finput"
              type="email"
              placeholder="client@example.com"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
          </label>
          <div className="ffield">
            <span>Тип</span>
            <Seg options={["Розница", "Тендер"]} value={tip} onChange={setTip} />
          </div>
          <div className="ffield">
            <span>Формат текста</span>
            <Seg options={["Текст", "Файл"]} value={fmt} onChange={(v) => { setFmt(v); setPhotoFallbackActive(false); setFileKind(null); }} />
          </div>
          <div className="ffield">
            <span>Монтаж</span>
            <Seg options={["Да", "Нет"]} value={mont} onChange={setMont} />
          </div>
          <div className="ffield">
            <span>Ценовая группа</span>
            <select className="tier-select" value={qtyTier} onChange={e => setQtyTier(e.target.value)}>
              <option>Авто</option>
              <option>Розница</option>
              <option>от 5 шт</option>
              <option>от 10 шт</option>
              <option>от 50 шт</option>
              <option>от 100 шт</option>
            </select>
          </div>
          <div className="ffield">
            <span>Доставка</span>
            <select className="tier-select" value={delivery} onChange={e => setDelivery(e.target.value)}>
              {DELIVERY_ZONES.map(z => <option key={z.label}>{z.label}</option>)}
            </select>
          </div>
        </div>

        {fmt === "Файл" && (
          <div className="ffield">
            <span>Файл заявки <i>(таблица, PDF или фото — необязательно, можно просто вставить текст ниже)</i></span>
            {!fileBusy && !photoFallbackActive && !photoSplitBusy && !photoLayoutBusy && !photoBusy && !photoReviewing && photoRows.length === 0 && photoPendingPages.length === 0 && photoItems.length === 0 && (
              <div className="fileDrop__card">
                <label
                  className={"fileDrop" + (dragOver ? " fileDrop--over" : "")}
                  onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
                  onDragLeave={() => setDragOver(false)}
                  onDrop={(e) => {
                    e.preventDefault();
                    setDragOver(false);
                    handleUnifiedFileSelect(e.dataTransfer.files);
                  }}
                >
                  <input
                    className="fileDrop__input"
                    type="file"
                    accept=".xlsx,.xls,.csv,application/pdf,image/*"
                    multiple
                    onChange={(e) => handleUnifiedFileSelect(e.target.files)}
                  />
                  <span className="fileDrop__text"><b>Перетащите файл сюда</b> или нажмите, чтобы выбрать</span>
                  <span className="fileDrop__hint2">таблица, PDF или фото — можно сразу несколько страниц</span>
                </label>
                <div className="fileDrop__divider"><span>или по ссылке</span></div>
                <div className="fileDrop__linkRow">
                  <input
                    type="text"
                    placeholder="https://disk.yandex.ru/i/…"
                    value={fileLink}
                    onChange={(e) => setFileLink(e.target.value)}
                    disabled={fileLinkBusy}
                  />
                  <button type="button" className="btn btn--ghost" onClick={handleUnifiedLink} disabled={!fileLink.trim() || fileLinkBusy}>
                    {fileLinkBusy ? "Загружаем…" : "Загрузить"}
                  </button>
                </div>
              </div>
            )}
            {fileBusy && <span className="photoUpload__status">Читаем файл…</span>}
            {fileName && !fileBusy && <span className="photoUpload__status">{fileName} — прочитан, проверьте текст ниже</span>}
            {tableWarnings.length > 0 && (
              <div className="tableWarnings">
                <div className="tableWarnings__title">Проверьте перед расчётом:</div>
                {tableWarnings.map((w, i) => (
                  <div key={i} className="tableWarnings__item">{w}</div>
                ))}
              </div>
            )}
            {photoSplitBusy && <span className="photoUpload__status">Разбираем PDF на страницы…</span>}
            {photoLayoutBusy && <span className="photoUpload__status">Определяем разметку…</span>}
            {photoPendingPages.length > 0 && (
              <>
                <div className="photoSplit__hint">
                  {photoPendingPages.some((p) => p.suggested)
                    ? "Похоже, нашли страницы с ведомостью проёмов — отметили их, можно поправить выбор. Клик по странице — выбрать/снять."
                    : "Отметьте страницы, которые относятся к заявке — на них уйдёт разметка, остальные не учитываются. Клик по странице — выбрать/снять."}
                </div>
                <div className="photoPages__grid">
                  {photoPendingPages.map((p, i) => (
                    <div
                      key={i}
                      className={"photoPages__item" + (p.keep ? " photoPages__item--selected" : "") + (p.suggested ? " photoPages__item--suggested" : "")}
                      onClick={() => togglePendingPage(i)}
                    >
                      <img src={p.previewUrl} alt={`Страница ${p.page || i + 1}`} />
                      <span className="photoPages__num">{p.page || i + 1}</span>
                      {p.suggested && <span className="photoPages__badge">похоже на нужную</span>}
                      {p.keep && <span className="photoPages__check">✓</span>}
                    </div>
                  ))}
                </div>
                <div className="photoPages__actions">
                  <button type="button" className="btn btn--ghost" onClick={handlePhotoPagesCancel}>
                    Отмена
                  </button>
                  <button
                    type="button"
                    className="btn btn--ghost"
                    onClick={handlePhotoPagesConfirm}
                    disabled={!photoPendingPages.some((p) => p.keep)}
                  >
                    Продолжить ({photoPendingPages.filter((p) => p.keep).length} из {photoPendingPages.length})
                  </button>
                </div>
              </>
            )}
            {photoItems.length > 0 && !photoLayoutBusy && !photoBusy && !photoReviewing && photoRows.length === 0 && (
              <>
                <div className="photoSplit__hint">
                  Так фото будет прочитано по кускам. По умолчанию всё исключено — выберите режим в панели ниже и отмечайте нужные куски: «Дверь» — позиция для расчёта, «Шапка» — строка с подписями столбцов (код сам определит Марку/Обозначение/Кол-во и применит ко всем строкам ниже), «Примечание» — общие указания, не дверь. Повторный клик тем же режимом снимает отметку. «Разметка» — клики по кускам не действуют, только двигайте линии между ними. «Линии» — клик внутри куска режет его в этой точке; клик по самой линии (без перетаскивания) убирает её, объединяя соседние куски. «Сбросить разметку» под фото — начать заново с нуля.
                </div>
                <div className="photoSplit__modes">
                  <Seg options={PHOTO_TAG_MODES} value={photoTagMode} onChange={setPhotoTagMode} />
                </div>
                <div className="photoSplit__list">
                  {photoItems.map((item, i) => (
                    <PhotoSplitEditor key={i} item={item} mode={photoTagMode} onChange={(next) => updatePhotoItem(i, next)} />
                  ))}
                </div>
                <button type="button" className="btn btn--ghost" onClick={handlePhotoRecognize}>
                  Распознать текст с фото
                </button>
              </>
            )}
            {(photoBusy || photoPendingCount > 0 || photoReviewing || photoRows.length > 0) && (
              <PhotoReviewScreen
                rows={photoRows}
                onFeedback={handlePhotoFeedback}
                onDone={handlePhotoReviewDone}
                busy={photoBusy || photoPendingCount > 0}
                progress={photoProgress ? { done: photoRows.length, total: photoProgress.total } : null}
              />
            )}
          </div>
        )}

        <label className="ffield">
          <span>Текст заявки</span>
          <textarea
            className="finput farea farea--tall"
            placeholder={photoFallbackActive
              ? "После загрузки фото здесь появится распознанный текст — проверьте и подправьте перед расчётом."
              : "Вставьте текст заявки:\n1. Дверь противопожарная EI-60, однопольная, правая, 2050×900, RAL 7035, сталь 1,5мм, 2 шт.\n2. Люк технический 600×600, 1 шт.\n…"}
            value={text}
            onChange={(e) => setText(e.target.value)}
          />
        </label>

        <label className="ffield">
          <span>Примечания <i>(необязательно)</i></span>
          <textarea
            className="finput farea"
            placeholder="Общие требования к проекту, условия поставки и т.п."
            value={note}
            onChange={(e) => setNote(e.target.value)}
          />
        </label>

        {errMsg && <div style={{ color: "#e5484d", fontSize: 13, marginTop: 4 }}>{errMsg}</div>}

        <div className="form__actions">
          <button
            className="btn btn--solid"
            onClick={handleCalc}
            disabled={busy || !text.trim()}
          >
            {busy ? "Обрабатывается…" : "Рассчитать"}
          </button>
          {fmt === "Файл" && !photoFallbackActive && (
            <button className="btn btn--ghost" onClick={handleNorm} disabled={normBusy || busy || !text.trim()}>
              {normBusy ? "Нормализую…" : "Нормализовать"}
            </button>
          )}
          <button className="btn btn--ghost" onClick={() => setShowReconcile(true)} disabled={busy || !text.trim()}>
            Сверка с КП
          </button>
        </div>
      </div>

      {showReconcile && (
        <div className="modalOverlay" onClick={() => !kpBusy && setShowReconcile(false)}>
          <div className="modalCard" onClick={(e) => e.stopPropagation()}>
            <h2 className="modalCard__ttl">Сверка с КП менеджера</h2>
            <div className="modalCard__hint">
              Заявка берётся из формы как есть (текст ниже). Здесь нужен только файл или ссылка на КП менеджера — покажем построчное сравнение с нашим расчётом.
            </div>
            <label
              className={"fileDrop" + (kpDragOver ? " fileDrop--over" : "")}
              onDragOver={(e) => { e.preventDefault(); setKpDragOver(true); }}
              onDragLeave={() => setKpDragOver(false)}
              onDrop={(e) => {
                e.preventDefault();
                setKpDragOver(false);
                const f = e.dataTransfer.files && e.dataTransfer.files[0];
                if (f) { setKpFile(f); setKpLink(""); }
              }}
            >
              <input
                className="fileDrop__input"
                type="file"
                accept=".xlsx,.xls"
                onChange={(e) => { const f = e.target.files[0]; if (f) { setKpFile(f); setKpLink(""); } }}
              />
              <span className="fileDrop__text">
                {kpFile ? <b>{kpFile.name}</b> : <><b>Перетащите КП сюда</b> или нажмите, чтобы выбрать</>}
              </span>
              <span className="fileDrop__hint2">Excel-файл КП менеджера</span>
            </label>
            <div className="fileDrop__divider"><span>или по ссылке</span></div>
            <div className="fileDrop__linkRow">
              <input
                type="text"
                placeholder="https://disk.yandex.ru/i/…"
                value={kpLink}
                onChange={(e) => { setKpLink(e.target.value); if (e.target.value) setKpFile(null); }}
                disabled={kpBusy}
              />
            </div>
            {kpErr && <div style={{ color: "#e5484d", fontSize: 13, marginTop: 8 }}>{kpErr}</div>}
            <div className="modalCard__actions">
              <button className="btn btn--ghost" onClick={() => setShowReconcile(false)} disabled={kpBusy}>Отмена</button>
              <button className="btn btn--solid" onClick={handleReconcile} disabled={kpBusy || (!kpFile && !kpLink.trim())}>
                {kpBusy ? "Сверяем…" : "Сверить"}
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

/* ---------- Сверка заявки с КП менеджера ---------- */

function fmtMoney(n) {
  return (n === null || n === undefined) ? "—" : Math.round(n).toLocaleString("ru-RU") + " ₽";
}

function describeKpRow(r) {
  return (r.mark ? `Марка ${r.mark}: ` : "") + (r.door_type || "") +
    (r.proem_h ? ` ${r.proem_h}×${r.proem_w}` : "") + (r.qty ? `, кол-во ${r.qty}` : "");
}

function ReconcileList({ title, items }) {
  if (!items || !items.length) return null;
  return (
    <div className="listBox">
      <h3>{title} ({items.length})</h3>
      <ul>{items.map((x, i) => <li key={i}>{x}</li>)}</ul>
    </div>
  );
}

function ReconcilePair({ pair }) {
  const label = pair.mark ? `Марка ${pair.mark}` : `Позиция ${pair.index + 1}`;
  if (pair.match_kind === "ambiguous") {
    return (
      <div className="posBlock">
        <div className="posHead">{label}<span className="tag warn">неоднозначно — {pair.kp_candidates.length} строк КП с этой маркой</span></div>
      </div>
    );
  }
  if (!pair.kp) {
    return (
      <div className="posBlock">
        <div className="posHead">{label}<span className="tag mismatch">не найдено в КП</span><span className="doorType">{pair.our["тип"]}</span></div>
      </div>
    );
  }
  const diffs = pair.diff.diffs;
  const priceCls = pair.diff.price ? (pair.diff.price.delta > 0 ? "pos" : pair.diff.price.delta < 0 ? "neg" : "") : "";
  return (
    <div className="posBlock">
      <div className="posHead">
        {label}
        {pair.match_kind === "order" && <span className="tag order">по порядку</span>}
        {diffs.length === 0 ? <span className="tag ok">совпало</span> : <span className="tag mismatch">{diffs.length} расхожд.</span>}
        <span className="doorType">{pair.our["тип"]} — {pair.kp.door_type}</span>
      </div>
      <table className="diffTbl">
        <tbody>
          <tr><th>Поле</th><th>Наш расчёт</th><th>КП менеджера</th></tr>
          {diffs.map((d, i) => (
            <tr className="diffRow" key={i}><td className="field">{d.field}</td><td>{String(d.our)}</td><td>{String(d.kp)}</td></tr>
          ))}
          {pair.diff.price && (
            <React.Fragment>
              <tr className="priceRow">
                <td>Цена изделия (итого на кол-во)</td><td>{fmtMoney(pair.diff.price.our)}</td><td>{fmtMoney(pair.diff.price.kp)}</td>
              </tr>
              <tr className="priceRow">
                <td className={"delta " + priceCls}>Δ цена</td>
                <td colSpan="2" className={"delta " + priceCls}>
                  {pair.diff.price.delta > 0 ? "+" : ""}{fmtMoney(pair.diff.price.delta)}
                  {pair.diff.price.pct !== null ? ` (${pair.diff.price.pct > 0 ? "+" : ""}${pair.diff.price.pct}%)` : ""}
                </td>
              </tr>
            </React.Fragment>
          )}
          {pair.diff.install && (
            <tr className="priceRow"><td>Монтаж (итого на кол-во)</td><td>{fmtMoney(pair.diff.install.our)}</td><td>{fmtMoney(pair.diff.install.kp)}</td></tr>
          )}
        </tbody>
      </table>
    </div>
  );
}

function ReconcilePage({ data, onBack }) {
  if (!data) {
    return (
      <div className="page" data-screen-label="Сверка с КП">
        <div className="dochead"><div className="dochead__left"><button className="back" onClick={onBack}>Назад</button></div></div>
        <div style={{ padding: 24, color: "var(--muted)" }}>Нет данных сверки.</div>
      </div>
    );
  }
  const matchedOk = data.pairs.filter((p) => p.diff && p.diff.diffs.length === 0).length;
  const matchedMismatch = data.pairs.filter((p) => p.diff && p.diff.diffs.length > 0).length;
  const notFound = data.pairs.filter((p) => !p.kp).length;

  return (
    <div className="page" data-screen-label="Сверка с КП">
      <div className="dochead">
        <div className="dochead__left">
          <button className="back" onClick={onBack}>Назад</button>
          <h1 className="ttl">Сверка заявки с КП менеджера</h1>
        </div>
      </div>
      <div className="reconcile">
        <div className="summary">
          <div className="card"><div className="n">{data.zayavka_positions}</div><div className="lbl">позиций в заявке</div></div>
          <div className="card"><div className="n">{data.kp_positions}</div><div className="lbl">позиций в КП</div></div>
          <div className="card"><div className="n">{matchedOk}</div><div className="lbl">совпало полностью</div></div>
          <div className="card"><div className="n">{matchedMismatch}</div><div className="lbl">есть расхождения</div></div>
          <div className="card"><div className="n">{notFound}</div><div className="lbl">не сопоставлено</div></div>
        </div>

        {data.pairs.map((pair, i) => <ReconcilePair key={i} pair={pair} />)}

        <ReconcileList
          title="Строки заявки без пары в КП"
          items={data.unmatched_our.map((u) => `Позиция ${u.index + 1}: ${u.position["тип"] || "?"} ${u.position["Проем_высота"] || ""}×${u.position["Проем_ширина"] || ""}`)}
        />
        <ReconcileList title="Строки КП без пары в заявке" items={data.unmatched_kp.map(describeKpRow)} />
        <ReconcileList title="Пропущенные строки заявки (не похоже на дверь)" items={data.skipped_rows} />
        <ReconcileList title="Доп. строки КП без размера (аксессуары/агрегаты)" items={data.kp_accessory_rows.map(describeKpRow)} />
      </div>
    </div>
  );
}

/* ---------- Тендер: сверка тендерной таблицы с чертежами РД ---------- */

function _diffSpan(text, isDiff) {
  return isDiff ? <b className="tcheck__diff">{text}</b> : 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 <span className="tcheck__aiPending" title="Второе мнение ИИ считается в фоне">🤖…</span>;
  }
  if (mode === "missing") {
    return (
      <div className={"tcheck__aiVerdict " + (review.maybe_present ? "tcheck__aiVerdict--unsure" : "tcheck__aiVerdict--same")}>
        <div className="tcheck__aiHead">🤖 {review.maybe_present ? "❓ возможно, упоминается в тексте" : "похоже, действительно нет"}</div>
        <div>{review.note}</div>
        <div className="tcheck__aiNote">второе мнение ИИ, не заменяет проверку человеком</div>
      </div>
    );
  }
  return (
    <div className={"tcheck__aiVerdict tcheck__aiVerdict--" + review.verdict}>
      <div className="tcheck__aiHead">
        🤖 {review.verdict === "same" ? "✅ похоже, одна дверь" : review.verdict === "different" ? "⚠ похоже, разные позиции" : "❓ не уверен"}
      </div>
      <div>{review.reasoning}</div>
      {(review.readings || []).map((rd, i) => (
        <div key={i} className="tcheck__aiReading">{rd.label}: {rd.mark} {rd.h_proem}×{rd.w_proem}</div>
      ))}
      <div className="tcheck__aiNote">второе мнение ИИ, не заменяет проверку человеком</div>
    </div>
  );
}

// Менеджер визуально проверил позицию и решил, что расхождение/"не найдено"
// на самом деле не проблема (реальный случай: ИИ нашёл "ДВ-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 (
    <span className="tcheck__confirm">
      <button
        type="button"
        className={"tcheck__confirmBtn" + (confirmed ? " tcheck__confirmBtn--on" : "")}
        onClick={toggle}
        disabled={busy}
        title={confirmed ? "Отменить подтверждение" : "Подтвердить: я проверил(а), это верно"}
      >
        {busy ? "…" : confirmed ? "✓ подтверждено" : "подтвердить"}
      </button>
      {err && <span className="tcheck__shotErr">{err}</span>}
    </span>
  );
}

// Для 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 (
    <span className="tcheck__shot">
      <button type="button" className="tcheck__shotBtn" onClick={handleClick} disabled={busy} title="Показать в тексте документа">
        {busy ? "…" : "📷"}
      </button>
      {err && <span className="tcheck__shotErr">{err}</span>}
      {result && !result.found && <span className="tcheck__shotErr">не нашёл в тексте страницы</span>}
      {zoomed && result && result.found && <TenderThumbZoom imageB64={result.image_b64} onClose={() => setZoomed(false)} />}
    </span>
  );
}

// Одна ячейка ведомости — что говорит ОДИН источник по этой позиции (см.
// форму 7/8 ГОСТ 21.1101: колонка "Обозначение" — на основании чего
// записана позиция). Расхождение уже посчитано на бэкенде (см.
// tender_reconcile._diff_fields) — здесь только показываем, какие поля
// именно разошлись, не утверждая, что это другая дверь (могла быть просто
// опечатка в одном из документов).
function TenderSourceCell({ info, projectId, mark, sourceLabel }) {
  if (info.status === "not_found") {
    return (
      <span className="tcheck__cellMiss">
        не найдено
        <TenderAiVerdict review={info.ai_review} mode="missing" />
        {info.source_file && (
          <TenderNotFoundShot projectId={projectId} filename={info.source_file} page={info.source_page} mark={mark} />
        )}
        <TenderManualConfirm projectId={projectId} mark={mark} sourceLabel={sourceLabel} serverConfirmed={info.manual_confirm} />
      </span>
    );
  }
  if (info.status === "ambiguous") {
    return (
      <span className="tcheck__cellAmb">
        неоднозначно ({info.candidates.length})
        <TenderAiVerdict review={info.ai_review} mode="compare" />
        <TenderManualConfirm projectId={projectId} mark={mark} sourceLabel={sourceLabel} serverConfirmed={info.manual_confirm} />
      </span>
    );
  }
  if (info.status === "mismatch") {
    return (
      <div className="tcheck__cellMismatch">
        {info.candidates.map((c, i) => {
          const diffs = c.diffs || [];
          return (
            <div key={i} className="tcheck__mismatchRow">
              ⚠ {_diffSpan(c.code, diffs.includes("code"))}
              {c.h_proem && c.w_proem ? <>, {_diffSpan(c.h_proem, diffs.includes("h_proem"))}×{_diffSpan(c.w_proem, diffs.includes("w_proem"))}</> : null}
              {c.naznach ? <> — {_diffSpan(String(c.naznach).replace(/\n/g, " "), diffs.includes("naznach"))}</> : null}
              <TenderRowShot projectId={projectId} filename={c._source_filename} page={c._page} bbox={c._bbox} />
              {c._sketch_bbox && (
                <TenderRowShot projectId={projectId} filename={c._source_filename} page={c._sketch_page} bbox={c._sketch_bbox} icon="🚪" label="Показать эскиз двери" />
              )}
            </div>
          );
        })}
        <TenderAiVerdict review={info.ai_review} mode="compare" />
        <TenderManualConfirm projectId={projectId} mark={mark} sourceLabel={sourceLabel} serverConfirmed={info.manual_confirm} />
      </div>
    );
  }
  const m = info.match;
  return (
    <div className="tcheck__cellOk">
      ✓ {m.h_proem}×{m.w_proem}
      {m.naznach ? <span className="tcheck__naznach"> — {String(m.naznach).replace(/\n/g, " ")}</span> : null}
      <TenderRowShot projectId={projectId} filename={m._source_filename} page={m._page} bbox={m._bbox} />
      {m._sketch_bbox && (
        <>
          <TenderRowShot projectId={projectId} filename={m._source_filename} page={m._sketch_page} bbox={m._sketch_bbox} icon="🚪" label="Показать эскиз двери" />
          <TenderGlazingCheck
            projectId={projectId}
            filename={m._source_filename}
            page={m._sketch_page}
            bbox={m._sketch_bbox}
            doorWmm={m.w_blok || m.w_proem}
            doorHmm={m.h_blok || m.h_proem}
          />
        </>
      )}
    </div>
  );
}

function TenderPrimaryConflict({ conflict, projectId }) {
  const diffs = conflict.diffs || [];
  const c = conflict.item;
  return (
    <div className="tcheck__mismatchRow">
      файл «{c._source_filename}»: {_diffSpan(c.code, diffs.includes("code"))}
      {c.h_proem && c.w_proem ? <>, {_diffSpan(c.h_proem, diffs.includes("h_proem"))}×{_diffSpan(c.w_proem, diffs.includes("w_proem"))}</> : null}
      {c.naznach ? <> — {_diffSpan(String(c.naznach).replace(/\n/g, " "), diffs.includes("naznach"))}</> : null}
      <TenderRowShot projectId={projectId} filename={c._source_filename} page={c._page} bbox={c._bbox} />
    </div>
  );
}

// Одна строка ведомости = одна позиция (марка). Колонки — как в форме 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 (
    <tr className={warn ? "tcheck__row--warn" : ""}>
      <td className="tcheck__colNum">{index + 1}</td>
      <td className="tcheck__colMark"><span className="pill">{r.mark}</span></td>
      <td className="tcheck__colName">
        {r.code}
        {r.own_size && (
          <div className="tcheck__declared">
            {r.own_size[0]}×{r.own_size[1]}
            {r.naznach ? ` — ${String(r.naznach).replace(/\n/g, " ")}` : ""}
          </div>
        )}
        {r.source_file && (
          <div className="tcheck__srcRef">
            {r.source_file}
            <TenderRowShot projectId={projectId} filename={r.source_file} page={r.page} bbox={r.bbox} />
          </div>
        )}
        {hasPrimaryConflict && (
          <div className="tcheck__cellMismatch">
            <div className="tcheck__mismatchHead">⚠ расходится с другим связующим файлом:</div>
            {r.primary_conflicts.map((conflict, i) => (
              <TenderPrimaryConflict key={i} conflict={conflict} projectId={projectId} />
            ))}
          </div>
        )}
        {r.size_matches === false && (
          <div className="tcheck__warn"><Warn size={12} /> размер по связующему файлу не совпадает ни с одним источником</div>
        )}
        {r.sources_disagree && (
          <div className="tcheck__warn"><Warn size={12} /> источники расходятся между собой: {r.resolved_sizes.map(s => `${s[0]}×${s[1]}`).join(", ")}</div>
        )}
      </td>
      <td className="tcheck__colQty">{r.qty}</td>
      {sourceLabels.map((label) => {
        const info = r.sources[label];
        return (
          <td key={label} className={"tcheck__srcCell" + (info ? " tcheck__srcCell--" + info.status : "")}>
            {info ? <TenderSourceCell info={info} projectId={projectId} mark={r.mark} sourceLabel={label} /> : "—"}
          </td>
        );
      })}
    </tr>
  );
}

function _pageKey(filename, page) {
  return filename + "|||" + page;
}

// Зеркалит main.py::_rd_project_label — тот же смысл: показать менеджеру,
// какому корпусу/секции/этажу принадлежит страница РД, не просто номер листа.
function _rdProjectLabel(p) {
  const parts = [];
  if (p.corpus != null) parts.push(`корпус ${p.corpus}`);
  if (p.floors) parts.push(p.floors[0] === p.floors[1] ? `этаж ${p.floors[0]}` : `этажи ${p.floors[0]}-${p.floors[1]}`);
  if (p.sections && p.sections.length) parts.push((p.sections.length > 1 ? "секции " : "секция ") + p.sections.join(", "));
  if (p.is_summary) parts.push("сводная");
  return parts.length ? parts.join(", ") : "проект не определён";
}

function TenderThumbZoom({ imageB64, onClose }) {
  return ReactDOM.createPortal(
    <div className="tcheck__zoomOverlay" onClick={onClose}>
      <img src={`data:image/jpeg;base64,${imageB64}`} onClick={(e) => e.stopPropagation()} />
    </div>,
    document.body
  );
}

// Скриншот КОНКРЕТНОЙ строки таблицы (не всей страницы) — подгружается лениво
// по клику (сессия на бэкенде живёт какое-то время после сверки специально
// для этого, см. main.py::tender_reconcile_endpoint). Работает только для
// позиций из PDF (есть bbox/page/source_filename — см. rd_extract._bbox);
// для позиций из Excel/Numbers скриншотить нечего, кнопка не показывается.
function TenderRowShot({ projectId, filename, page, bbox, icon = "📷", label = "Показать строку из документа" }) {
  const [img, setImg] = usePagesState(null);
  const [busy, setBusy] = usePagesState(false);
  const [zoomed, setZoomed] = usePagesState(false);
  const [err, setErr] = usePagesState("");

  if (!projectId || !filename || !page || !bbox) return null;

  const handleClick = async () => {
    if (img) {
      setZoomed(true);
      return;
    }
    setBusy(true);
    setErr("");
    try {
      const [x0, y0, x1, y1] = bbox;
      const url = `/api/tender-projects/${projectId}/row-thumb?filename=${encodeURIComponent(filename)}&page=${page}&x0=${x0}&y0=${y0}&x1=${x1}&y1=${y1}`;
      const r = await fetchWithRetry(url, { method: "GET" });
      if (!r.ok) throw new Error(await r.text());
      const data = await r.json();
      setImg(data.image_b64);
      setZoomed(true);
    } catch (e) {
      setErr("не загрузилось: " + e.message);
    } finally {
      setBusy(false);
    }
  };

  return (
    <span className="tcheck__shot">
      <button type="button" className="tcheck__shotBtn" onClick={handleClick} disabled={busy} title={label}>
        {busy ? "…" : icon}
      </button>
      {err && <span className="tcheck__shotErr">{err}</span>}
      {zoomed && img && <TenderThumbZoom imageB64={img} onClose={() => setZoomed(false)} />}
    </span>
  );
}

// Экспериментальная проверка остекления по эскизу через Roboflow (см.
// roboflow_vision.py) — модель обучена на другом типе изображений (фото/скан,
// не векторный чертёж КАД), поэтому результат может быть неточным или вообще
// не найтись — показываем как есть, с уверенностью модели, ничего не
// округляем и не подтверждаем сами.
function TenderGlazingCheck({ projectId, filename, page, bbox, doorWmm, doorHmm }) {
  const [result, setResult] = usePagesState(null);
  const [busy, setBusy] = usePagesState(false);
  const [open, setOpen] = usePagesState(false);
  const [err, setErr] = usePagesState("");

  if (!projectId || !filename || !page || !bbox || !doorWmm || !doorHmm) return null;

  const handleClick = async () => {
    if (result) {
      setOpen((v) => !v);
      return;
    }
    setBusy(true);
    setErr("");
    try {
      const [x0, y0, x1, y1] = bbox;
      const url = `/api/tender-projects/${projectId}/sketch-glazing?filename=${encodeURIComponent(filename)}&page=${page}&x0=${x0}&y0=${y0}&x1=${x1}&y1=${y1}&door_w_mm=${doorWmm}&door_h_mm=${doorHmm}`;
      const r = await fetchWithRetry(url, { method: "GET" });
      if (!r.ok) throw new Error(await r.text());
      const data = await r.json();
      setResult(data);
      setOpen(true);
    } catch (e) {
      setErr("не загрузилось: " + e.message);
    } finally {
      setBusy(false);
    }
  };

  return (
    <span className="tcheck__shot">
      <button type="button" className="tcheck__shotBtn" onClick={handleClick} disabled={busy} title="Проверить остекление по эскизу (Roboflow, экспериментально)">
        {busy ? "…" : "🔍"}
      </button>
      {err && <span className="tcheck__shotErr">{err}</span>}
      {open && result && (
        <div className="tcheck__glazing">
          {!result.found ? (
            <span className="tcheck__glazingMiss">остекление не распознано</span>
          ) : (
            <>
              <div>{result.door_type === "double" ? "двупольная" : "однопольная"} (увер. {Math.round(result.door_confidence * 100)}%)</div>
              {result.panes.map((p, i) => (
                <div key={i}>стекло: {p.width_mm}×{p.height_mm} мм (увер. {Math.round(p.confidence * 100)}%)</div>
              ))}
              {result.incomplete && <div className="tcheck__glazingMiss">⚠ возможно, найдено не всё остекление</div>}
            </>
          )}
        </div>
      )}
    </span>
  );
}


function TenderRdFilePicker({ rdFile, selected, onToggle, projectId, fileId }) {
  const [thumbs, setThumbs] = usePagesState(null); // {page: image_b64}
  const [zoomedPage, setZoomedPage] = usePagesState(null);

  usePagesEffect(() => {
    if (!projectId || !fileId) return;
    let cancelled = false;
    const url = `/api/tender-projects/${projectId}/files/${fileId}/thumbs`;
    fetch(url)
      .then((r) => (r.ok ? r.json() : null))
      .then((data) => {
        if (cancelled || !data) return;
        const map = {};
        for (const t of data.pages) map[t.page] = t.image_b64;
        setThumbs(map);
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [projectId, fileId]);

  return (
    <div className="tcheck__rdFile">
      <div className="tcheck__rdFileName">{rdFile.filename}</div>
      <div className="tcheck__rdPages">
        {rdFile.pages.map((p) => {
          const key = _pageKey(rdFile.filename, p.page);
          const thumb = thumbs && thumbs[p.page];
          return (
            <div key={key} className="tcheck__pageRow">
              <label className="tcheck__pageOpt">
                <input type="checkbox" checked={selected.has(key)} onChange={() => onToggle(key)} />
                стр.{p.page} — {_rdProjectLabel(p)}
              </label>
              {thumb && (
                <img
                  className="tcheck__pageThumb"
                  src={`data:image/jpeg;base64,${thumb}`}
                  alt={`стр. ${p.page}`}
                  title="Нажмите, чтобы увеличить"
                  onClick={() => setZoomedPage(p.page)}
                />
              )}
            </div>
          );
        })}
      </div>
      {zoomedPage && thumbs && thumbs[zoomedPage] && (
        <TenderThumbZoom imageB64={thumbs[zoomedPage]} onClose={() => setZoomedPage(null)} />
      )}
    </div>
  );
}

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 (
    <div className="tcheck__rdFile">
      <div className="tcheck__rdFileName">{linkFile.filename}</div>
      <div className="tcheck__rdPages">
        {linkFile.sheets.map((s) => {
          const key = _sheetKey(linkFile.filename, s.sheet);
          return (
            <label key={key} className="tcheck__pageOpt">
              <input type="checkbox" checked={selected.has(key)} onChange={() => onToggle(key)} />
              лист «{s.sheet}» — {s.mark_count} марок{s.value_fallback.length > 0 ? " (колонки определены по виду значений)" : ""}
            </label>
          );
        })}
      </div>
    </div>
  );
}

// Кандидатов (какие листы/страницы вообще есть в файле) может не быть в
// candidates, если менеджер выбрал страницу вручную через миниатюры (см.
// TenderManualPagePicker) — автораспознавание её не нашло, но выбор уже
// сохранён в БД. Добавляем такую страницу как отдельную строку, иначе выбор
// есть, а галочки для него не видно.
function _mergedPageCandidates(file) {
  const known = new Set((file.candidates || []).map((c) => c.page));
  const extra = file.selections
    .map((s) => Number(s.selector))
    .filter((p) => !known.has(p))
    .map((p) => ({ page: p, corpus: null, floors: null, sections: null, is_summary: false }));
  return [...(file.candidates || []), ...extra].sort((a, b) => a.page - b.page);
}

function TenderManualPagePicker({ projectId, file, onPicked }) {
  const [thumbs, setThumbs] = usePagesState(null);
  const [thumbsBusy, setThumbsBusy] = usePagesState(false);
  const [pickBusy, setPickBusy] = usePagesState(null);
  const [msg, setMsg] = usePagesState({});
  const [err, setErr] = usePagesState("");

  const loadThumbs = async () => {
    setThumbsBusy(true);
    setErr("");
    try {
      const r = await fetchWithRetry(`/api/tender-projects/${projectId}/files/${file.id}/thumbs`, { method: "GET" });
      if (!r.ok) throw new Error(await r.text());
      setThumbs((await r.json()).pages);
    } catch (e) {
      setErr("Ошибка загрузки миниатюр: " + e.message);
    } finally {
      setThumbsBusy(false);
    }
  };

  const pick = async (page) => {
    setPickBusy(page);
    try {
      const fd = new FormData();
      fd.append("page", page);
      const r = await fetchWithRetry(`/api/tender-projects/${projectId}/files/${file.id}/manual-page`, { method: "POST", body: fd });
      if (!r.ok) {
        const errText = await r.text();
        setMsg((prev) => ({ ...prev, [page]: "ошибка: " + errText }));
        return;
      }
      const data = await r.json();
      setMsg((prev) => ({ ...prev, [page]: `найдено ${data.mark_count} марок` }));
      await onPicked(page);
    } catch (e) {
      setMsg((prev) => ({ ...prev, [page]: "ошибка: " + e.message }));
    } finally {
      setPickBusy(null);
    }
  };

  return (
    <div className="ffield">
      {!thumbs && (
        <button type="button" className="btn btn--ghost" onClick={loadThumbs} disabled={thumbsBusy}>
          {thumbsBusy ? "Открываем…" : `Не то, что нужно? Показать все страницы (${file.pdf_pages || "?"})`}
        </button>
      )}
      {err && <div className="tcheck__error">{err}</div>}
      {thumbs && (
        <div className="tcheck__thumbGrid">
          {thumbs.map((t) => (
            <div key={t.page} className="tcheck__thumb">
              <img src={`data:image/jpeg;base64,${t.image_b64}`} alt={`стр. ${t.page}`} onClick={() => pick(t.page)} />
              <div className="tcheck__thumbLabel">
                стр. {t.page}
                {pickBusy === t.page && " — проверяем…"}
                {msg[t.page] && <span> — {msg[t.page]}</span>}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function TenderCheckPage() {
  const [projects, setProjects] = usePagesState([]);
  const [projectId, setProjectIdState] = usePagesState(() => {
    const saved = localStorage.getItem("ferro_tender_project_id");
    return saved ? Number(saved) : null;
  });
  const [project, setProject] = usePagesState(null);
  const [report, setReport] = usePagesState(null);
  const [newProjectName, setNewProjectName] = usePagesState("");
  const [busy, setBusy] = usePagesState(false);
  const [errMsg, setErrMsg] = usePagesState("");
  const [progress, setProgress] = usePagesState("");
  const [pendingPrimaryFile, setPendingPrimaryFile] = usePagesState(null);
  const [pendingSourceFile, setPendingSourceFile] = usePagesState(null);

  const setProjectId = (id) => {
    if (id) localStorage.setItem("ferro_tender_project_id", String(id));
    else localStorage.removeItem("ferro_tender_project_id");
    setProjectIdState(id);
  };

  const loadProjects = async () => {
    try {
      const r = await fetchWithRetry("/api/tender-projects", { method: "GET" });
      if (r.ok) setProjects(await r.json());
    } catch (e) { /* список проектов необязателен для работы, тихо игнорируем */ }
  };

  const loadProjectAndReport = async (id) => {
    try {
      const [pr, rr] = await Promise.all([
        fetchWithRetry(`/api/tender-projects/${id}`, { method: "GET" }),
        fetchWithRetry(`/api/tender-projects/${id}/reconcile`, { method: "GET" }),
      ]);
      if (!pr.ok) throw new Error(await pr.text());
      if (!rr.ok) throw new Error(await rr.text());
      setProject(await pr.json());
      setReport(await rr.json());
    } catch (e) {
      setErrMsg("Ошибка загрузки проекта: " + e.message);
    }
  };

  usePagesEffect(() => { loadProjects(); }, []);

  usePagesEffect(() => {
    if (projectId) {
      loadProjectAndReport(projectId);
    } else {
      setProject(null);
      setReport(null);
    }
  }, [projectId]);

  // Второе мнение ИИ по подозрительным позициям (см. ai_review.py) считается
  // в фоне на бэкенде, не по клику — ai_review приходит null, пока ещё не
  // досчиталось. Пока в ведомости есть хоть одна такая позиция, тихо
  // перезапрашиваем /reconcile раз в 5с — свежие вердикты появятся сами,
  // без перезагрузки страницы. Как только null не осталось — опрос сам
  // прекращается (эффект больше не перезапускает таймер).
  usePagesEffect(() => {
    if (!projectId || !report) return;
    const pending = report.results.some((r) =>
      Object.values(r.sources).some((info) => info && info.ai_review === null)
    );
    if (!pending) return;
    const t = setTimeout(async () => {
      try {
        const rr = await fetchWithRetry(`/api/tender-projects/${projectId}/reconcile`, { method: "GET" });
        if (rr.ok) setReport(await rr.json());
      } catch (e) { /* фоновое обновление необязательно, тихо игнорируем */ }
    }, 5000);
    return () => clearTimeout(t);
  }, [report, projectId]);

  const handleCreateProject = async () => {
    if (!newProjectName.trim()) return;
    setBusy(true);
    setErrMsg("");
    try {
      const fd = new FormData();
      fd.append("name", newProjectName.trim());
      const r = await fetchWithRetry("/api/tender-projects", { method: "POST", body: fd });
      if (!r.ok) throw new Error(await r.text());
      const data = await r.json();
      setNewProjectName("");
      await loadProjects();
      setProjectId(data.id);
    } catch (e) {
      setErrMsg("Ошибка создания проекта: " + e.message);
    } finally {
      setBusy(false);
    }
  };

  const handleDeleteProject = async () => {
    if (!projectId || !project) return;
    if (!window.confirm(`Удалить проект «${project.name}» вместе со всеми файлами? Это нельзя отменить.`)) return;
    setBusy(true);
    setErrMsg("");
    try {
      const r = await fetchWithRetry(`/api/tender-projects/${projectId}`, { method: "DELETE" });
      if (!r.ok) throw new Error(await r.text());
      setProjectId(null);
      await loadProjects();
    } catch (e) {
      setErrMsg("Ошибка удаления проекта: " + e.message);
    } finally {
      setBusy(false);
    }
  };

  const handleAddFile = async (file, role) => {
    if (!file || !projectId) return;
    setBusy(true);
    setErrMsg("");
    try {
      setProgress(`Загружаем ${file.name}…`);
      const token = await uploadFileChunked(file);
      const fd = new FormData();
      fd.append("file_token", token);
      fd.append("filename", file.name);
      fd.append("role", role);
      const r = await fetchWithRetry(`/api/tender-projects/${projectId}/files`, { method: "POST", body: fd });
      if (!r.ok) throw new Error(await r.text());
      const data = await r.json();
      if (role === "primary") setPendingPrimaryFile(null); else setPendingSourceFile(null);
      // кандидат единственный — сразу отмечаем, незачем заставлять кликать лишний раз
      if (data.candidates.length === 1) {
        const only = data.kind === "sheet" ? data.candidates[0].sheet : data.candidates[0].page;
        const selFd = new FormData();
        selFd.append("selected", JSON.stringify([only]));
        await fetchWithRetry(`/api/tender-projects/${projectId}/files/${data.file_id}/select`, { method: "POST", body: selFd });
      }
      await loadProjectAndReport(projectId);
    } catch (e) {
      setErrMsg("Ошибка загрузки файла: " + e.message);
    } finally {
      setBusy(false);
      setProgress("");
    }
  };

  const toggleSelector = async (file, rawValue) => {
    const currentRaw = new Set(file.selections.map((s) => String(s.selector)));
    const key = String(rawValue);
    if (currentRaw.has(key)) currentRaw.delete(key); else currentRaw.add(key);
    const newSelected = Array.from(currentRaw).map((v) => (file.kind === "page" ? Number(v) : v));
    setErrMsg("");
    try {
      const fd = new FormData();
      fd.append("selected", JSON.stringify(newSelected));
      const r = await fetchWithRetry(`/api/tender-projects/${projectId}/files/${file.id}/select`, { method: "POST", body: fd });
      if (!r.ok) throw new Error(await r.text());
      await loadProjectAndReport(projectId);
    } catch (e) {
      setErrMsg("Ошибка сохранения выбора: " + e.message);
    }
  };

  const handleDeleteFile = async (fileId) => {
    setErrMsg("");
    try {
      const r = await fetchWithRetry(`/api/tender-projects/${projectId}/files/${fileId}`, { method: "DELETE" });
      if (!r.ok) throw new Error(await r.text());
      await loadProjectAndReport(projectId);
    } catch (e) {
      setErrMsg("Ошибка удаления файла: " + e.message);
    }
  };

  const _rawFromKey = (key) => key.slice(key.lastIndexOf("|||") + 3);

  if (!projectId) {
    return (
      <div className="page" data-screen-label="Тендер — сверка">
        <div className="pagehead">
          <h1 className="ttl">Тендер — сверка с чертежами</h1>
        </div>
        <div className="form">
          {projects.length > 0 && (
            <div className="ffield">
              <span>Открыть проект <i>(файлы копятся по частям — можно продолжить в любой момент)</i></span>
              <div className="tcheck__projectList">
                {projects.map((p) => (
                  <button key={p.id} type="button" className="btn btn--ghost" onClick={() => setProjectId(p.id)}>
                    {p.name} <i>({p.file_count} файл(ов))</i>
                  </button>
                ))}
              </div>
            </div>
          )}
          <div className="ffield">
            <span>Новый проект тендера</span>
            <div className="photoUpload">
              <input
                type="text" placeholder="Например: Сетунь, Самолёт" value={newProjectName}
                onChange={(e) => setNewProjectName(e.target.value)} disabled={busy}
              />
              <button type="button" className="btn btn--solid" onClick={handleCreateProject} disabled={busy || !newProjectName.trim()}>
                {busy ? "Создаём…" : "Создать"}
              </button>
            </div>
          </div>
          {errMsg && <div className="tcheck__error">{errMsg}</div>}
        </div>
      </div>
    );
  }

  if (!project) {
    return (
      <div className="page" data-screen-label="Тендер — сверка">
        <div className="pagehead"><h1 className="ttl">Тендер — сверка с чертежами</h1></div>
        <div className="form">{errMsg ? <div className="tcheck__error">{errMsg}</div> : "Загружаем проект…"}</div>
      </div>
    );
  }

  const primaryFiles = project.files.filter((f) => f.role === "primary");
  const sourceFiles = project.files.filter((f) => f.role === "source");

  return (
    <div className="page" data-screen-label="Тендер — сверка">
      <div className="pagehead">
        <h1 className="ttl">{project.name}</h1>
        <div className="tcheck__projectBar">
          <button type="button" className="btn btn--ghost" onClick={() => setProjectId(null)}>← другой проект</button>
          <button type="button" className="btn btn--ghost tcheck__deleteProject" onClick={handleDeleteProject}>Удалить проект</button>
        </div>
      </div>

      {errMsg && <div className="tcheck__error">{errMsg}</div>}

      <div className="form">
        <div className="tcheck__stepHead">
          <div>
            <div className="tcheck__stepTitle">Связующие файлы</div>
            <div className="tcheck__stepHint">
              Любой файл, где для каждой марки есть <b>размер</b> — единственное, от чего можно оттолкнуться (в тендерной
              таблице марок нет). Добавляйте по одному — не нужно собирать все сразу, список можно пополнять в течение
              всей работы над тендером.
            </div>
          </div>
        </div>

        {primaryFiles.map((f) => (
          <div key={f.id} className="tcheck__fileBlock">
            <div className="tcheck__fileBlockHead">
              <b>{f.filename}</b>
              <button type="button" className="tcheck__fileRemove" onClick={() => handleDeleteFile(f.id)} title="Убрать файл из проекта">✕</button>
            </div>
            {f.kind === "sheet" ? (
              <TenderLinkFilePicker
                linkFile={{ filename: f.filename, sheets: f.candidates }}
                selected={new Set(f.selections.map((s) => _sheetKey(f.filename, s.selector)))}
                onToggle={(key) => toggleSelector(f, _rawFromKey(key))}
              />
            ) : (
              <>
                <TenderRdFilePicker
                  rdFile={{ filename: f.filename, pages: _mergedPageCandidates(f) }}
                  selected={new Set(f.selections.map((s) => _pageKey(f.filename, Number(s.selector))))}
                  onToggle={(key) => toggleSelector(f, Number(_rawFromKey(key)))}
                  projectId={projectId}
                  fileId={f.id}
                />
                <TenderManualPagePicker projectId={projectId} file={f} onPicked={(page) => toggleSelector(f, page)} />
              </>
            )}
          </div>
        ))}

        <div className="ffield">
          <span>Добавить связующий файл</span>
          <div className="photoUpload">
            <input type="file" onChange={(e) => setPendingPrimaryFile((e.target.files || [])[0] || null)} disabled={busy} />
            {busy && <span className="photoUpload__status">{progress || "Обрабатываем…"}</span>}
            {!busy && pendingPrimaryFile && (
              <button type="button" className="btn btn--ghost" onClick={() => handleAddFile(pendingPrimaryFile, "primary")}>
                Загрузить «{pendingPrimaryFile.name}»
              </button>
            )}
          </div>
        </div>
      </div>

      <div className="form">
        <div className="tcheck__stepHead">
          <div>
            <div className="tcheck__stepTitle">Файлы для сверки</div>
            <div className="tcheck__stepHint">Чертежи РД (PDF) — сверяем с ними марки связующих файлов. Тоже можно добавлять по одному.</div>
          </div>
        </div>

        {sourceFiles.map((f) => (
          <div key={f.id} className="tcheck__fileBlock">
            <div className="tcheck__fileBlockHead">
              <b>{f.filename}</b>
              <button type="button" className="tcheck__fileRemove" onClick={() => handleDeleteFile(f.id)} title="Убрать файл из проекта">✕</button>
            </div>
            <TenderRdFilePicker
              rdFile={{ filename: f.filename, pages: _mergedPageCandidates(f) }}
              selected={new Set(f.selections.map((s) => _pageKey(f.filename, Number(s.selector))))}
              onToggle={(key) => toggleSelector(f, Number(_rawFromKey(key)))}
              projectId={projectId}
              fileId={f.id}
            />
            <TenderManualPagePicker projectId={projectId} file={f} onPicked={(page) => toggleSelector(f, page)} />
          </div>
        ))}

        <div className="ffield">
          <span>Добавить файл для сверки (чертёж РД, PDF)</span>
          <div className="photoUpload">
            <input type="file" onChange={(e) => setPendingSourceFile((e.target.files || [])[0] || null)} disabled={busy} />
            {busy && <span className="photoUpload__status">{progress || "Обрабатываем…"}</span>}
            {!busy && pendingSourceFile && (
              <button type="button" className="btn btn--ghost" onClick={() => handleAddFile(pendingSourceFile, "source")}>
                Загрузить «{pendingSourceFile.name}»
              </button>
            )}
          </div>
        </div>
      </div>

      {report && (
        <div className="tcheck__report">
          <div className="tcheck__stepHead">
            <div>
              <div className="tcheck__stepTitle">Сопоставительная ведомость по маркам</div>
              <div className="tcheck__stepHint">
                Формат — как у сопоставительной ведомости объёмов работ (Приложение №12 методики определения сметной
                стоимости) и спецификации по ГОСТ 21.1101 (форма 7/8: поз./наименование/кол-во): каждая марка ({report.primary_count})
                и что о ней говорит каждый отмеченный источник, с прослеживаемостью до конкретного листа документа.
                Обновляется автоматически при любом изменении файлов или выбора.
              </div>
            </div>
            {report.results.length > 0 && (
              <a
                className="btn"
                href={`/api/tender-projects/${projectId}/reconcile.xlsx`}
                download
              >
                Скачать ведомость (.xlsx)
              </a>
            )}
          </div>
          <div className="tcheck__summary">
            {report.primary_sources.map((f) => <span key={f} className="pill">Связующий файл: {f}</span>)}
          </div>
          {report.other_files.length > 0 && (
            <div className="tcheck__other">
              Не использованы для сверки: {report.other_files.map((f) => `${f.filename} (${f.role})`).join(", ")}
            </div>
          )}
          {report.results.length === 0 ? (
            <div className="tcheck__other">Отметьте листы/страницы связующих файлов выше, чтобы увидеть ведомость.</div>
          ) : (
            <div className="tcheck__vedomostWrap">
              <table className="tcheck__vedomost">
                <thead>
                  <tr>
                    <th>№</th>
                    <th>Марка (поз.)</th>
                    <th>Наименование — по связующему файлу</th>
                    <th>Кол-во</th>
                    {report.sources.map((s) => <th key={s}>{s}</th>)}
                  </tr>
                </thead>
                <tbody>
                  {report.results.map((r, i) => (
                    <TenderVedomostRow key={r.mark + i} r={r} index={i} sourceLabels={report.sources} projectId={projectId} />
                  ))}
                </tbody>
              </table>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

Object.assign(window, { ZayavkiPage, NewCalcPage, TenderCheckPage });
