const { useState, useEffect, useMemo, useRef } = React; const C = window.CATALOGS; // All pure logic (parser, classification, cut/pcs maths, Gola conversion, hinge // suggestion, hardware rows, payload builder) lives in import-core.js so it can // be unit tested under node. This file is presentation only. const IC = window.ImportCore; /* ---------- Telegram Mini App bridge ---------- */ const TG = (window.Telegram && window.Telegram.WebApp) || null; // ready()/expand()/fullscreen are handled by tg-init.js on every page. const TG_LIMIT = 4096; // Telegram's hard cap on sendData // Telegram measures the cap in UTF-8 bytes, and Cyrillic costs two per char, // so the length of the string is not a safe proxy. function byteLen(str) { return new TextEncoder().encode(str).length; } // Returns { ok, json, bytes, dropped, sent }. Over the cap we shed `todo` lines // (they are advisory) and only give up once there is nothing left to shed. They // go from the front: the builder emits the generic "no such row" warnings first // and the actionable ones last, so the front is what we can most afford to lose. function sendToBot(payload) { let json = JSON.stringify(payload); let dropped = 0; while (byteLen(json) > TG_LIMIT && Array.isArray(payload.todo) && payload.todo.length) { payload.todo.shift(); dropped++; json = JSON.stringify(payload); } const bytes = byteLen(json); if (bytes > TG_LIMIT) return { ok: false, json, bytes, dropped, sent: false }; if (TG && typeof TG.sendData === "function") { TG.sendData(json); return { ok: true, json, bytes, dropped, sent: true }; } console.log("[payload]", json); // plain browser: nothing to send to, so show it return { ok: true, json, bytes, dropped, sent: false }; } /* ---------------- i18n ---------------- */ const I18N = { ro: { pasteTitle: "Date PRO100", pasteHint: "Lipește aici textul copiat din PRO100…", parse: "Procesează", clear: "Șterge", parsed: "Date procesate cu succes", nothing: "Apasă „Procesează\" după ce lipești textul.", invalid: "Nu s-a putut detecta formatul PRO100. Verifică textul.", pieces: "Piese", elements: "Elemente", materials: "Materiale", fatade: "Fațade", corp: "Corpuri (PAL)", spate: "Spate (HDF/ДВП)", blatGroup: "Blat", golaGroup: "Gola", accesorii: "Accesorii", neclasificat: "Neclasificat", extraGroup: "Necorelat", qty: "Buc", dim: "Dimensiuni (mm)", thick: "Gros.", name: "Denumire", matCorpuri: "Material corpuri", matFatade: "Material fațade", blatCb: "Blat + Contrablat", blatLbl: "Blat", contrablatLbl: "Contrablat", contrablatLen: "Lungime contrablat (m.l.)", addModel: "Adaugă model", removeModel: "Șterge modelul", modelN: "Model", qtyM2: "Cantitate (m²)", qtyMp: "Cantitate (m.l.)", autoQty: "auto: restul din PRO100", feronerie: "Feronerie / Фурнитура", chooseBrand: "Alege brandul…", chooseFatadeType: "Alege tipul de fațadă…", chooseBlatType: "Alege tipul de blat…", pickArticle: "Selectează articolul", type: "Tip", brand: "Brand", article: "Cod / denumire", feronerieBrand: "Brand feronerie", balamale: "Balamale", placute: "Plăcuțe", picioruse: "Picioare", ghidaje: "Ghidaje", manere: "Mânere", kargo: "Kargo (sertare metalice)", other: "Alte accesorii", hwKind: "Categorie", hwCode: "Cod", hwAdd: "Adaugă rând", hwDel: "Șterge rândul", hwColor: "Culoare", colorNone: "—", colorHint: "ex. Антрацит", hwHint: "Cantitatea de balamale este doar o sugestie — verifică-o.", golaTitle: "Profil Gola", golaArea: "Suprafață (m²)", golaMp: "Lungime (m.l.)", golaHint: (h, mm) => `Sugestie: suprafața ÷ ${h} m (profil de ${mm} mm). Poți corecta.`, led: "LED", ledHint: "Activează / dezactivează", services: "Preț servicii adiționale", noteTitle: "Comentarii adiționale", notePh: "Observații pentru calcul…", montare: "Montare prefilat", livrare: "Livrare", proiectare: "Proiectare", tehnolog: "Tehnolog", transport: "Transport", calc: "Calculează", sub: "Lipește textul exportat din PRO100 (Copiază tot) în câmpul de mai jos.", totalPieces: "Total piese", uniqueMaterials: "Materiale", consumption: "Consum", stripWidth: "Lățime bandă", matSpate: "Material spate (HDF)", sentOk: "Datele au fost trimise botului.", payloadTitle: "Date pentru bot", payloadHint: "Telegram nu este disponibil — datele sunt afișate mai jos.", tooBig: "Datele depășesc limita Telegram", dropped: "linii „todo\" au fost eliminate pentru a încăpea în limită.", dbDown: "Baza de date indisponibilă — introduceți codurile manual", codeHint: "Cod (sau alege din bază)", brandHint: "Brand (opțional)", }, en: { pasteTitle: "PRO100 Data", pasteHint: "Paste the PRO100 export text here…", parse: "Process", clear: "Clear", parsed: "Data processed successfully", nothing: "Click 'Process' after pasting the text.", invalid: "Couldn't detect PRO100 format. Check the text.", pieces: "Parts", elements: "Elements", materials: "Materials", fatade: "Fronts", corp: "Bodies (PAL)", spate: "Backs (HDF)", blatGroup: "Countertop", golaGroup: "Gola", accesorii: "Accessories", neclasificat: "Unclassified", extraGroup: "Unmatched", qty: "Qty", dim: "Dimensions (mm)", thick: "Thick.", name: "Name", matCorpuri: "Body material", matFatade: "Front material", blatCb: "Countertop + Backsplash", blatLbl: "Countertop", contrablatLbl: "Backsplash", contrablatLen: "Backsplash length (lin. m)", addModel: "Add model", removeModel: "Remove model", modelN: "Model", qtyM2: "Quantity (m²)", qtyMp: "Quantity (lin. m)", autoQty: "auto: the rest of the PRO100 total", feronerie: "Hardware / Фурнитура", chooseBrand: "Choose brand…", chooseFatadeType: "Choose front type…", chooseBlatType: "Choose countertop type…", pickArticle: "Select article", type: "Type", brand: "Brand", article: "Code / name", feronerieBrand: "Hardware brand", balamale: "Hinges", placute: "Mounting plates", picioruse: "Legs", ghidaje: "Slides", manere: "Handles", kargo: "Kargo (metal drawers)", other: "Other accessories", hwKind: "Category", hwCode: "Code", hwAdd: "Add row", hwDel: "Remove row", hwColor: "Colour", colorNone: "—", colorHint: "e.g. Антрацит", hwHint: "The hinge quantity is only a suggestion — please check it.", golaTitle: "Gola profile", golaArea: "Area (m²)", golaMp: "Length (lin. m)", golaHint: (h, mm) => `Suggestion: area ÷ ${h} m (${mm} mm profile). You can correct it.`, led: "LED", ledHint: "Enable / disable", services: "Additional services price", noteTitle: "Additional comments", notePh: "Notes for the calculation…", montare: "Pre-installed mounting", livrare: "Delivery", proiectare: "Design", tehnolog: "Technologist", transport: "Transport", calc: "Calculate", sub: "Paste the PRO100 export (Copy all) into the field below.", totalPieces: "Total parts", uniqueMaterials: "Materials", consumption: "Consumption", stripWidth: "Strip width", matSpate: "Back material (HDF)", sentOk: "Data sent to the bot.", payloadTitle: "Bot payload", payloadHint: "Telegram is unavailable — the data is shown below.", tooBig: "Data exceeds the Telegram limit", dropped: "“todo” lines were dropped to fit the limit.", dbDown: "Database unavailable — enter the codes manually", codeHint: "Code (or pick from the database)", brandHint: "Brand (optional)", }, ru: { pasteTitle: "Данные PRO100", pasteHint: "Вставьте сюда текст из PRO100…", parse: "Обработать", clear: "Очистить", parsed: "Данные успешно обработаны", nothing: "Нажмите «Обработать» после вставки текста.", invalid: "Не удалось определить формат PRO100. Проверьте текст.", pieces: "Детали", elements: "Элементы", materials: "Материалы", fatade: "Фасады", corp: "Корпус (ДСП)", spate: "Задняя стенка (ДВП)", blatGroup: "Столешница", golaGroup: "Gola", accesorii: "Фурнитура", neclasificat: "Без категории", extraGroup: "Не сопоставлено", qty: "Шт", dim: "Размеры (мм)", thick: "Толщ.", name: "Название", matCorpuri: "Материал корпуса", matFatade: "Материал фасадов", blatCb: "Столешница + Стеновая панель", blatLbl: "Столешница", contrablatLbl: "Стеновая панель", contrablatLen: "Длина стеновой панели (м.п.)", addModel: "Добавить модель", removeModel: "Удалить модель", modelN: "Модель", qtyM2: "Количество (м²)", qtyMp: "Количество (м.п.)", autoQty: "авто: остаток расхода PRO100", feronerie: "Фурнитура / Feronerie", chooseBrand: "Выберите бренд…", chooseFatadeType: "Выберите тип фасада…", chooseBlatType: "Выберите тип столешницы…", pickArticle: "Выберите артикул", type: "Тип", brand: "Бренд", article: "Код / название", feronerieBrand: "Бренд фурнитуры", balamale: "Петли", placute: "Планки", picioruse: "Ножки", ghidaje: "Направляющие", manere: "Ручки", kargo: "Карго (метал. ящики)", other: "Другая фурнитура", hwKind: "Категория", hwCode: "Код", hwAdd: "Добавить строку", hwDel: "Удалить строку", hwColor: "Цвет", colorNone: "—", colorHint: "напр. Антрацит", hwHint: "Количество петель — только подсказка, проверьте его.", golaTitle: "Профиль Gola", golaArea: "Площадь (м²)", golaMp: "Длина (м.п.)", golaHint: (h, mm) => `Подсказка: площадь ÷ ${h} м (профиль ${mm} мм). Можно исправить.`, led: "LED", ledHint: "Включить / выключить", services: "Цена доп. услуг", noteTitle: "Дополнительные комментарии", notePh: "Замечания к расчёту…", montare: "Сборка предустан.", livrare: "Доставка", proiectare: "Проектирование", tehnolog: "Технолог", transport: "Транспорт", calc: "Рассчитать", sub: "Вставьте текст из PRO100 (Копировать всё) в поле ниже.", totalPieces: "Всего деталей", uniqueMaterials: "Материалов", consumption: "Расход", stripWidth: "Ширина полосы", matSpate: "Материал спинки (ДВП)", sentOk: "Данные отправлены боту.", payloadTitle: "Данные для бота", payloadHint: "Telegram недоступен — данные показаны ниже.", tooBig: "Данные превышают лимит Telegram", dropped: "строк «todo» удалено, чтобы уложиться в лимит.", dbDown: "База недоступна — введите коды вручную", codeHint: "Код (или выберите из базы)", brandHint: "Бренд (необязательно)", }, }; const SVC_STD = IC.SVC_STD; // The standards this page falls back to when GET /api/defaults is unreachable. // Built from the core constants, so the page and the tests cannot drift apart. const STD_FALLBACK = IC.defaultsFallback(); const HW_KINDS = ["balamale", "placute", "picioruse", "ghidaje", "kargo", "manere", "other"]; /* ---------- Pieces grouped display ---------- */ function PiecesCard({ t, pieces }) { const groups = useMemo(() => IC.piecesByCategory(pieces), [pieces]); const renderGroup = (list, label, key) => { if (!list || list.length === 0) return null; const totalQty = list.reduce((s, p) => s + p.qty, 0); return (
{label}
{list.length} tip · {totalQty} buc
{list.map((p, i) => ( ))}
{t.name} {t.dim}{t.thick}{t.qty}
{p.type || "—"} {p.length} × {p.width} {p.thickness} {p.qty}
); }; const accesorii = groups.picioruse.concat(groups.ghidaje, groups.manere); return (

{t.pieces}

{renderGroup(groups.fatade, t.fatade, "fatade")} {renderGroup(groups.corp, t.corp, "corp")} {renderGroup(groups.spate, t.spate, "spate")} {renderGroup(groups.blat, t.blatGroup, "blat")} {renderGroup(groups.gola, t.golaGroup, "gola")} {renderGroup(accesorii, t.accesorii, "accesorii")} {renderGroup(groups.extra, t.extraGroup, "extra")}
); } /* ---------- Stats and materials summary ---------- */ function StatsCard({ t, parsed }) { const totalPieces = parsed.pieces.reduce((s, p) => s + p.qty, 0); return (

{t.elements}

{t.totalPieces}
{totalPieces}
{parsed.elements.map((e, i) => (
{e.type || t.neclasificat}
{e.count}
))}
); } function MaterialsCard({ t, materials }) { if (!materials || materials.length === 0) return null; return (

{t.materials} ({t.consumption})

{materials.map((m, i) => (
{m.path}
{m.amount} {m.unit}
))}
); } /* ---------- Database-backed picker fields ---------- Every material is a free-text code the manager can always type, plus a button that fills it in from the live price base. The typed code is the fallback for a dead API, so it is never disabled and never gated on a brand being chosen. `unit` and `price` ride along from a pick for display only: payload v2 carries neither, and buildImportPayload copies its fields one by one, so they cannot leak into what the bot receives. */ // Exact `category` values in data/*.json; defined once in picker.jsx. const DB_CAT = window.DbCat; // What a picked position contributes to a material selection. function fromItem(it) { return { code: it.code || "", name: it.name || it.code || "", unit: it.unit || "", price: it.price }; } // A code typed by hand: the name mirrors it, the display-only fields go. function fromTyped(code) { return { code: code, name: code, unit: "", price: undefined }; } /* Free-text brand with the known brands offered as suggestions — a pick from the database may carry a brand that is not in the static list. */ function BrandField({ t, value, onChange, options, listId }) { return (
onChange(e.target.value)} /> {(options || []).map(b =>
); } function CodeField({ t, lang, value, onTyped, onOpen }) { return (
onTyped(e.target.value)} />
); } /* Name and price of whatever is currently selected, when they add anything to the code already shown in the input. */ function PickedLine({ value }) { const bits = []; if (value.name && value.name !== value.code) bits.push(value.name); if (value.price === 0 || value.price) { bits.push(value.price + " MDL" + (value.unit ? " / " + value.unit : "")); } if (!bits.length) return null; return
{bits.join(" \u00b7 ")}
; } /* ---------- Colour (ТЗ-2 п.9) ---------- The colour a hardware row or the Gola profile should come in. The choices are what GET /api/colors knows for the same slice the picker searches (a brand, or a category), fetched once per slice and kept for the page. */ const colorSliceKey = (f) => (f.brand ? "b:" + f.brand : "c:" + (f.category || "")); const hasColorApi = () => !!(window.Api && window.Api.colors); // Colours compare like the API filter does: no case, no ё/е difference. const colorNorm = (c) => String(c || "").trim().toLowerCase().replace(/ё/g, "е"); /* { sliceKey: [colours] | "loading" | "error" } for every slice in `filters` ({ brand, category } objects); each slice is requested once. */ function useColorOptions(filters) { const [cache, setCache] = useState({}); const asked = useRef({}); const keys = []; const bySlice = {}; filters.forEach((f) => { const k = colorSliceKey(f); if (!bySlice[k]) { bySlice[k] = f; keys.push(k); } }); const keyStr = keys.join("|"); /* Mounted, not "this effect run is current": a slice asked for is never asked again, so its answer must land even after the set of slices has changed (a brand picked, a row's kind switched), or it would stay "loading". */ const mounted = useRef(true); useEffect(() => () => { mounted.current = false; }, []); useEffect(() => { if (!hasColorApi()) return; keys.forEach((k) => { if (asked.current[k]) return; asked.current[k] = true; const f = bySlice[k]; setCache((c) => ({ ...c, [k]: "loading" })); window.Api.colors(f.brand ? { brand: f.brand } : { category: f.category || "" }).then( (list) => { if (mounted.current) setCache((c) => ({ ...c, [k]: Array.isArray(list) ? list : [] })); }, () => { // A failed slice may be asked again the next time it is shown. asked.current[k] = false; if (mounted.current) setCache((c) => ({ ...c, [k]: "error" })); } ); }); }, [keyStr]); return cache; } /* A select over the known colours, plus «—» and whatever is chosen now even when the base does not list it. Without the API, after a failed load, or when the slice has no colours at all, it is a plain text input. */ function ColorField({ t, value, options, onChange }) { const cur = value || ""; const list = Array.isArray(options) ? options : []; const asText = !hasColorApi() || options === "error" || (Array.isArray(options) && list.length === 0); // The list's own spelling of the current value, so «антрацит» selects «Антрацит» // instead of adding a second, duplicate option. const listed = list.find((c) => colorNorm(c) === colorNorm(cur)); const shown = listed !== undefined ? listed : cur; return (
{asText ? ( onChange(e.target.value)} /> ) : ( )}
); } /* ---------- Several models of one material ---------- Corp, fatade and blat can each be split across several models. A model is { sel, qty, touched }: `sel` is what the pickers edit, `qty` what the manager typed. How the PRO100 total is shared out is IC.splitQty's call, and the same function feeds the payload, so what is shown is what is sent. */ // `id` is the React key only: rows are removed from the middle, so an index key // would hand one model's picker state to the next. It never reaches the payload. let modelSeq = 0; const newModel = (sel) => ({ id: ++modelSeq, sel: sel, qty: "", touched: false }); // A model list back to «nothing typed»: a new project has new totals. const resetQty = (rows) => rows.map((m) => ({ ...m, qty: "", touched: false })); function modelOps(rows, setRows, blank) { const setSel = (i, sel) => setRows(rows.map((m, j) => (j === i ? { ...m, sel } : m))); // An empty field is automatic again. const setQty = (i, raw) => setRows(rows.map((m, j) => (j === i ? { ...m, qty: raw, touched: raw !== "" } : m))); const add = () => setRows(rows.concat([newModel(blank())])); // A lone model always carries the whole total, so whatever was typed goes. const remove = (i) => { const next = rows.filter((_, j) => j !== i); setRows(next.length === 1 ? resetQty(next) : next); }; return { setSel, setQty, add, remove }; } function ModelHead({ t, i, count, onRemove }) { if (count < 2) return null; return (
{t.modelN} {i + 1}
); } /* The quantity of one model. A single model shows the PRO100 total read-only; with several, an untouched field shows its automatic share as a placeholder. */ function ModelQty({ t, label, rows, i, shares, onQty }) { const m = rows[i]; if (rows.length < 2) { return (
); } const auto = !(m.touched && m.qty !== ""); return (
onQty(i, e.target.value)} /> {auto &&
{t.autoQty}: {shares[i]}
}
); } function AddModel({ t, onAdd }) { return (
); } function CorpModel({ t, lang, i, value, setValue }) { const [modalOpen, setModalOpen] = useState(false); return (
setValue({ ...value, brand: b })} /> setModalOpen(true)} onTyped={(code) => setValue({ ...value, ...fromTyped(code) })} />
{modalOpen && ( setValue({ ...value, brand: it.brand || value.brand, ...fromItem(it) })} onClose={() => setModalOpen(false)} /> )}
); } function CorpuriPicker({ t, lang, rows, setRows, total }) { const ops = modelOps(rows, setRows, () => ({ brand: "", code: "", name: "", price: 0 })); const shares = IC.splitQty(total, rows); return (

{t.matCorpuri}

{rows.map((m, i) => (
{i > 0 &&
} ops.remove(i)} /> ops.setSel(i, v)} />
))}
); } function FatadeModel({ t, lang, i, value, setValue }) { const [modalOpen, setModalOpen] = useState(false); return (
{/* A code belongs to one front type, so switching the type drops it. */}
setValue({ ...value, brand: b })} />
setModalOpen(true)} onTyped={(code) => setValue({ ...value, ...fromTyped(code) })} />
{modalOpen && ( setValue({ ...value, brand: it.brand || value.brand, ...fromItem(it) })} onClose={() => setModalOpen(false)} /> )}
); } function FatadePicker({ t, lang, rows, setRows, total }) { const ops = modelOps(rows, setRows, () => ({ type: "", brand: "", code: "", name: "", price: 0 })); const shares = IC.splitQty(total, rows); return (

{t.matFatade}

{rows.map((m, i) => (
{i > 0 &&
} ops.remove(i)} /> ops.setSel(i, v)} />
))}
); } /* Shown when there is nothing to send to (plain browser) or the payload could not be sent — never let a failed send look like a success. */ function PayloadModal({ t, state, onClose }) { return (
e.stopPropagation()}>

{state.ok ? t.payloadTitle : t.tooBig}

{state.ok ? t.payloadHint : `${t.tooBig}: ${state.bytes} / ${TG_LIMIT} bytes`}
{state.dropped > 0 && (
{state.dropped} {t.dropped}
)}
{state.bytes} / {TG_LIMIT} bytes
{state.json}
); } function SpatePicker({ t, lang, value, setValue }) { const [modalOpen, setModalOpen] = useState(false); return (

{t.matSpate}

setModalOpen(true)} onTyped={(code) => setValue({ ...value, ...fromTyped(code) })} />
{modalOpen && ( setValue({ ...value, ...fromItem(it) })} onClose={() => setModalOpen(false)} /> )}
); } const STRIP_WIDTHS = [600, 900, 1200]; /* blat = { blat: [models], contrablat: sel }. Every countertop model has its own type, code, strip width and share of the PRO100 metres; the backsplash stays a single selection cut from the first model's strip. */ function BlatPicker({ t, lang, blat, setBlat, total, contrablatLen, setContrablatLen, contrablatTouched, onContrablatTouched }) { // "contrablat", or the index of the countertop model whose picker is open. const [modalKey, setModalKey] = useState(null); const rows = blat.blat; const setRows = (next) => setBlat({ ...blat, blat: next }); const ops = modelOps(rows, setRows, () => ({ type: "", brand: "", code: "", name: "", price: 0 })); const shares = IC.splitQty(total, rows); // Every edit made through the contrablat row's own controls goes through here. // The first blat model's article picker copies its decor across with setBlat // directly, which is what keeps an auto-filled contrablat from counting as a choice. const updateContra = (patch) => { if (onContrablatTouched) onContrablatTouched(); setBlat({ ...blat, contrablat: { ...blat.contrablat, ...patch } }); }; const updateModel = (i, patch) => ops.setSel(i, { ...rows[i].sel, ...patch }); /* The contrablat decor was copied from the first model. When that model goes and the manager never chose a contrablat themselves, the copy follows the new first model instead of pointing at a countertop that is not there. */ const removeModel = (i) => { setModalKey(null); const left = rows.filter((_, j) => j !== i); const next = left.length === 1 ? resetQty(left) : left; if (i !== 0 || contrablatTouched) { setBlat({ ...blat, blat: next }); return; } const first = next[0].sel; setBlat({ ...blat, blat: next, contrablat: { ...blat.contrablat, type: first.type || "", brand: first.brand || "", code: first.code || "", name: first.name || "", unit: first.unit || "", price: first.price, }, }); }; const setWidth = (i, w) => setRows(rows.map((m, j) => (j === i ? { ...m, w } : m))); const brandsFor = (type) => type === "HPL" ? C.HPL_BRANDS : type === "Standart 38mm" ? C.STANDARD_BRANDS : C.HPL_BRANDS.concat(C.STANDARD_BRANDS); const renderSel = (b, listId, onPatch, onOpen) => (
{/* A code belongs to one countertop type, so switching it drops both the code and the brand it came from. */}
onPatch({ brand: v })} />
onPatch(fromTyped(code))} />
); const pickedFor = modalKey === "contrablat" ? blat.contrablat : (modalKey !== null && rows[modalKey] ? rows[modalKey].sel : null); return (

{t.blatCb}

{rows.map((m, i) => (
{t.blatLbl}{rows.length > 1 ? " — " + t.modelN + " " + (i + 1) : ""}
{rows.length > 1 && ( )}
{renderSel(m.sel, "dl-brand-blat-" + i, (patch) => updateModel(i, patch), () => setModalKey(i))}
{/* The strip width always ships in the payload: the bot needs it to turn a per-m² price into a per-running-metre one. */}
))}
{t.contrablatLbl}
{renderSel(blat.contrablat, "dl-brand-contrablat", updateContra, () => setModalKey("contrablat"))} {/* PRO100 never reports the backsplash, so its length is typed by hand. */}
setContrablatLen(e.target.value)} />
{pickedFor && ( { const picked = fromItem(it); if (modalKey === "contrablat") { updateContra({ brand: it.brand || blat.contrablat.brand, ...picked }); return; } const cur = rows[modalKey].sel; const nextSel = { ...cur, brand: it.brand || cur.brand, ...picked }; const nextRows = rows.map((m, j) => (j === modalKey ? { ...m, sel: nextSel } : m)); // Selecting on the first countertop model auto-fills contrablat (one-way) setBlat(modalKey === 0 ? { ...blat, blat: nextRows, contrablat: { ...blat.contrablat, type: cur.type, brand: it.brand || cur.brand, ...picked }, } : { ...blat, blat: nextRows }); }} onClose={() => setModalKey(null)} /> )}
); } /* ---------- Gola ---------- Shown only when the materials section carried a Gola row. PRO100 gives an area; the profile is bought by the running metre, so the area is read-only and the metres are editable. */ function GolaCard({ t, lang, areaM2, gola, setGola, golaHM }) { const [modalOpen, setModalOpen] = useState(false); // The height is whatever the standards say, so the hint cannot claim 50 mm // while the conversion divides by something else. const hM = Number(golaHM) > 0 ? Number(golaHM) : IC.GOLA_PROFILE_H_M; const hMm = Math.round(hM * 1000); const hStr = lang === "en" ? String(hM) : String(hM).replace(".", ","); const golaSlice = { category: DB_CAT.gola }; const colorOpts = useColorOptions([golaSlice]); return (

{t.golaTitle}

setGola({ ...gola, mp: e.target.value })} />
setModalOpen(true)} onTyped={(code) => setGola({ ...gola, ...fromTyped(code) })} /> setGola({ ...gola, color })} />
{t.golaHint(hStr, hMm)}
{modalOpen && ( setGola({ ...gola, ...fromItem(it) })} onClose={() => setModalOpen(false)} /> )}
); } /* ---------- Hardware ---------- Prefilled from the PRO100 parts and elements lists, then fully editable: the manager owns the final numbers, the app only proposes them. */ function FeronerieCard({ t, lang, brand, setBrand, rows, setRows, placuteEdited, setPlacuteEdited, colors }) { // Which row's picker is open, or null. Hinges and legs get a category hint; // everything else searches the whole base, because the manager may put any // accessory on an "other" row. const [pickRow, setPickRow] = useState(null); const catFor = (kind) => DB_CAT[kind] || ""; /* Hinges, plates and guides follow the chosen brand. Brands file the same article under different categories (DTC hinges sit in «Фурнитура», not «Петли»), so with a brand the category hint is dropped and the brand filter does the narrowing. */ const BRAND_KINDS = ["balamale", "placute", "ghidaje"]; const pickerFilter = (kind) => (brand && BRAND_KINDS.indexOf(kind) >= 0) ? { brand, category: "" } : { brand: undefined, category: catFor(kind) }; const update = (i, patch) => { setRows(rows.map((r, j) => (j === i ? { ...r, ...patch } : r))); }; // Colour choices follow the same slice the row's picker searches. const colorOpts = useColorOptions(rows.map((r) => pickerFilter(r.kind))); const onQty = (i, raw) => { const value = raw === "" ? "" : Number(raw); const row = rows[i]; if (row.kind === "placute") { setPlacuteEdited(true); update(i, { qty: value }); return; } // Mounting plates track the hinges until the manager overrides them. if (row.kind === "balamale" && !placuteEdited) { setRows(rows.map((r, j) => { if (j === i) return { ...r, qty: value }; if (r.kind === "placute") return { ...r, qty: value }; return r; })); return; } update(i, { qty: value }); }; /* Plates come in the hinges' colour until the manager sets the plates' own colour or quantity, the same rule as for the quantity. */ const onColor = (i, color) => { const row = rows[i]; if (row.kind === "placute") { setPlacuteEdited(true); update(i, { color }); return; } if (row.kind === "balamale" && !placuteEdited) { setRows(rows.map((r, j) => ((j === i || r.kind === "placute") ? { ...r, color } : r))); return; } update(i, { color }); }; const removeRow = (i) => setRows(rows.filter((_, j) => j !== i)); const addRow = () => setRows(rows.concat([{ kind: "other", code: "", name: "", qty: "", color: IC.colorFor(colors, "other"), }])); return (

{t.feronerie}

{rows.map((r, i) => (
onQty(i, e.target.value)} />
update(i, { code: e.target.value, price: undefined, unit: "" })} /> setPickRow(i)} />
update(i, { name: e.target.value })} />
onColor(i, color)} /> {(r.price === 0 || r.price) && (
{r.price} MDL{r.unit ? " / " + r.unit : ""}
)}
))}
{t.hwHint}
{pickRow !== null && rows[pickRow] && ( update(pickRow, { code: it.code || "", name: it.name || it.code || "", unit: it.unit || "", price: it.price, })} onClose={() => setPickRow(null)} /> )}
); } /* ---------- Root app ---------- */ function LedCard({ t, value, setValue, count, setCount }) { return (
{t.led}
{value && ( setCount(e.target.value)} /> )}
); } /* The manager overrides a service price by typing over it. The standard stays untouched underneath, so clearing the field falls back to it instead of leaving the service at nothing. */ function ServicesCard({ t, services, setServices, servicesPrice }) { const rows = [ { key: "montare", label: t.montare, unit: "MDL/m²", std: SVC_STD.montare }, { key: "livrare", label: t.livrare, unit: "MDL", std: SVC_STD.livrare }, { key: "proiectare", label: t.proiectare, unit: "MDL", std: SVC_STD.proiectare }, { key: "tehnolog", label: t.tehnolog, unit: "MDL/m²", std: SVC_STD.tehnolog }, { key: "transport", label: t.transport, unit: "MDL", std: SVC_STD.transport }, ]; const update = (k, v) => setServices((s) => ({ ...s, [k]: v })); return (

{t.services}

{rows.map((r) => { const stdValue = servicesPrice[r.key] ?? r.std; const displayValue = services[r.key] !== "" && services[r.key] !== undefined ? services[r.key] : (stdValue === "" ? "" : stdValue); return (
{ const v = e.target.value; update(r.key, v === "" ? "" : Number(v)); }} /> {r.unit}
); })}
); } /* Free-text comment from the manager. It rides to the bot as payload `note` and is never trimmed to fit the Telegram cap: if the payload is still too big after shedding todo lines, PayloadModal reports it instead. */ const NOTE_MAX = 400; function NoteCard({ t, note, setNote }) { return (

{t.noteTitle}

{/* Capped so a long comment can never push the payload over Telegram's 4096-byte sendData limit: sendToBot would then silently shed the constructor's todo lines to make room. 400 chars of Cyrillic is 800 bytes; a three-models-per-material order on the ТЗ fixture is about 2.8 KB, so this keeps a few hundred bytes of headroom. */}