// ferro-app.jsx — Ferrolend КП detail page with click-to-expand manager calc const { useState, useMemo, useRef, useEffect } = React; /* ============================ small bits ============================ */ function DownloadIcon() { return ( ); } function Pill({ children }) { return {children}; } function Chevron({ open }) { return ( ); } function Warn({ size = 15 }) { return ( ); } // Подсказка "откуда взято значение" — заменяет нативный title. source бывает: // null/undefined (сработал дефолт), объект {before, match, after} (дословная // цитата с контекстом — see extractor._ctx_source) или обычная строка // (описание вроде "рассчитано по проёму ..." — не цитата, просто пояснение). function SourceTip({ source, defaultText, children }) { const [pos, setPos] = useState(null); // [БАГ] Раньше позиция считалась через ref.getBoundingClientRect() — но обёртка // .stip-anchor это display:contents (нужно, чтобы не ломать вёрстку ячейки // таблицы), а у display:contents элементов нет собственного box'а, поэтому // getBoundingClientRect() возвращал пустой прямоугольник (0,0) — подсказка // улетала в левый верхний угол экрана. Позиционируем прямо от курсора // (координаты события) — заодно это и есть то, что просили: рядом с курсором. const place = (e) => { const x = Math.max(6, Math.min(e.clientX + 12, window.innerWidth - 340)); const y = Math.min(e.clientY + 16, window.innerHeight - 20); setPos({ x, y }); }; const hide = () => setPos(null); let body; if (!source) { body = {defaultText || "По умолчанию — в тексте заявки не указано"}; } else if (typeof source === "string") { body = source.startsWith("рассчитано") ? <>Рассчитано: {source} : <>Из текста заявки: {source}; } else { body = ( <> Из текста заявки:
{source.before ? `…${source.before}` : ""} {source.match} {source.after ? `${source.after}…` : ""}
); } return ( {children} {pos && ReactDOM.createPortal(
{body}
, document.body )}
); } function PinIcon() { return ( ); } function BellIcon() { return ( ); } /* ============================ changelog / "что нового" ============================ */ const CHANGELOG_SEEN_KEY = "ferro_changelog_seen"; function ChangelogButton() { const [open, setOpen] = useState(false); const [seen, setSeen] = useState(() => { try { return localStorage.getItem(CHANGELOG_SEEN_KEY) || ""; } catch (e) { return ""; } }); const ref = useRef(null); const latest = CHANGELOG[0]?.date || ""; const hasNew = latest && latest > seen; useEffect(() => { if (!open) return; const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, [open]); const toggle = () => { setOpen((o) => { const next = !o; if (next && latest) { try { localStorage.setItem(CHANGELOG_SEEN_KEY, latest); } catch (e) {} setSeen(latest); } return next; }); }; const fmtShort = (iso) => { const d = new Date(iso + "T00:00:00"); return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }); }; return (
{open && (
Что нового
)}
); } /* ============================ position-text info popup ============================ */ function InfoPopup({ x, y, pinned, tab, positionText, fullTextNormalized, fullTextRaw, originalText, hasOpenPosition, reqId, onRawTextDone, onClose, onTogglePin, onSetTab, onMove }) { const ref = useRef(null); useEffect(() => { if (!pinned) return; const onDown = (e) => { if (ref.current && ref.current.contains(e.target)) return; if (e.target.closest(".tablewrap") || e.target.closest(".poscards") || e.target.closest(".infobtn")) return; onClose(); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); // eslint-disable-next-line }, [pinned]); const startDrag = (e) => { e.preventDefault(); const startX = e.clientX, startY = e.clientY; const origX = x, origY = y; const onMouseMove = (ev) => { const w = ref.current ? ref.current.offsetWidth : 360; const nx = Math.max(8, Math.min(origX + (ev.clientX - startX), window.innerWidth - w - 8)); const ny = Math.max(8, Math.min(origY + (ev.clientY - startY), window.innerHeight - 40)); onMove(nx, ny); }; const onUp = () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onUp); }; const body = tab === "position" ? (hasOpenPosition ? (positionText || "Текст не найден") : "Сначала откройте позицию в таблице") : tab === "original" ? (originalText || "Оригинал не сохранён — нормализация не применялась") : tab === "full" ? (fullTextNormalized || "Текст не найден") : null; // tab === "raw" — рендерится через RawTextTab ниже return ReactDOM.createPortal(
e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
Текст заявки
{originalText && ( )}
{tab === "raw" && reqId ? ( ) : body}
, document.body ); } function ReprocessBtn({ reqId, onDone }) { const [state, setState] = useState('idle'); // idle | running | done | error const run = async () => { if (state === 'running') return; setState('running'); try { await apiFetch('POST', `/requests/${reqId}/reprocess`); // Poll until ready for (let i = 0; i < 60; i++) { await new Promise(r => setTimeout(r, 2000)); const d = await apiFetch('GET', `/requests/${reqId}`); if (d.status === 'ready') { setState('done'); onDone(); setTimeout(() => setState('idle'), 2000); return; } if (d.status === 'error') { setState('error'); setTimeout(() => setState('idle'), 3000); return; } } setState('error'); setTimeout(() => setState('idle'), 3000); } catch (e) { setState('error'); setTimeout(() => setState('idle'), 3000); } }; const icon = state === 'running' ? '⟳' : state === 'done' ? '✓' : state === 'error' ? '✕' : '⟳'; const title = state === 'running' ? 'Пересчёт…' : state === 'done' ? 'Готово' : state === 'error' ? 'Ошибка' : 'Пересчитать заявку'; return ( ); } // Менеджер редактирует текст заявки ПРЯМО НА МЕСТЕ (карандашик → текст становится // редактируемым тут же, без отдельного попапа/блока) — вписывает то, чего не // написал клиент, но видно на приложенном чертеже/фото, РОВНО туда, где в тексте // описана нужная дверь (дописывание в конец не годится: при нескольких позициях // непонятно, к какой из них относится добавленное). Галочка слева от карандашика // сохраняет и сразу пересчитывает заявку заново (см. /api/requests/{id}/raw-text). function RawTextTab({ reqId, text, onDone }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(text || ""); const [state, setState] = useState("idle"); // idle | saving | done | error useEffect(() => { if (!editing) setDraft(text || ""); }, [text, editing]); const save = async () => { if (state === "saving") return; setState("saving"); try { await apiFetch("PATCH", `/requests/${reqId}/raw-text`, { text: draft }); for (let i = 0; i < 60; i++) { await new Promise((r) => setTimeout(r, 2000)); const d = await apiFetch("GET", `/requests/${reqId}`); if (d.status === "ready") { setState("done"); setEditing(false); onDone(); setTimeout(() => setState("idle"), 1500); return; } if (d.status === "error") { setState("error"); setTimeout(() => setState("idle"), 3000); return; } } setState("error"); setTimeout(() => setState("idle"), 3000); } catch (e) { setState("error"); setTimeout(() => setState("idle"), 3000); } }; const toggleEdit = () => { if (editing) setDraft(text || ""); setEditing((e) => !e); }; return (
{editing && ( )}
{editing ? (