/* standards-app.jsx — the «Standarde / Стандарты» page. * * Reads data/defaults.json over GET /api/defaults, lets the manager change the * hardware and material standards, the Gola profile height and the standard * service prices, and writes them back with PUT /api/defaults (docs/api.md). * Presentation only: the shape of the standards and the merge over the built-in * fallback live in import-core.js, where they are unit tested. */ const { useState, useEffect } = React; const IC = window.ImportCore; /* ---------------- i18n ---------------- */ const I18N = { ro: { title: "Standarde", sub: "Valorile implicite pe care pagina de import le completează automat.", hwTitle: "Feronerie standard", matTitle: "Materiale standard", golaTitle: "Profil Gola", svcTitle: "Preț servicii standard", code: "Cod", name: "Denumire", clear: "Golește", balamale: "Balamale", placute: "Plăcuțe", picioruse: "Picioare", ghidaje: "Ghidaje", spate: "Spate (HDF/ДВП)", gola: "Profil Gola", golaH: "Înălțime profil (mm)", golaHHint: "Folosită la conversia m² → m.l. pe pagina de import.", montare: "Montare prefilat", livrare: "Livrare", proiectare: "Proiectare", tehnolog: "Tehnolog", transport: "Transport", svcHint: "Lasă câmpul gol dacă serviciul nu are preț standard.", save: "Salvează", saving: "Se salvează…", saved: "Standardele au fost salvate.", loading: "Se încarcă…", dbDown: "Baza de date indisponibilă — valorile afișate sunt cele implicite, salvarea nu va funcționa.", errSave: "Salvarea a eșuat", errTimeout: "Baza de date nu răspunde.", emptyHint: "Gol = nu există standard pentru această poziție.", brandTitle: "Feronerie pe brand", brandHint: "Brandul ales pe pagina de import decide ce balamale, plăcuțe și ghidaje se completează. Gol = se folosește feroneria standard de mai sus.", extraTitle: "Articole suplimentare", extraHint: "Dacă un element din proiectul PRO100 conține fragmentul, articolul intră în feronerie cu cantitatea elementului. Ghidajele («Направляющие») nu sunt afectate de aceste reguli: ele se stabilesc în cardul «Feronerie pe brand».", extraMatch: "Fragment din denumirea PRO100", extraMatchPh: "ex. Kargo", extraAdd: "Adaugă articol", extraDel: "Șterge", extraEmpty: "Nu există articole suplimentare.", colorsTitle: "Culori implicite", colorsHint: "Culoarea pe care pagina de import o pune pe rândurile de feronerie de acest tip; căutarea în bază filtrează după ea. Gol = fără culoare.", colorOther: "Alte accesorii", colorNone: "—", colorPh: "ex. Антрацит", }, en: { title: "Standards", sub: "The default values the import page fills in automatically.", hwTitle: "Standard hardware", matTitle: "Standard materials", golaTitle: "Gola profile", svcTitle: "Standard service prices", code: "Code", name: "Name", clear: "Clear", balamale: "Hinges", placute: "Mounting plates", picioruse: "Legs", ghidaje: "Slides", spate: "Back (HDF)", gola: "Gola profile", golaH: "Profile height (mm)", golaHHint: "Used for the m² → linear metre conversion on the import page.", montare: "Pre-installed mounting", livrare: "Delivery", proiectare: "Design", tehnolog: "Technologist", transport: "Transport", svcHint: "Leave a field empty when the service has no standard price.", save: "Save", saving: "Saving…", saved: "Standards saved.", loading: "Loading…", dbDown: "Database unavailable — the values shown are the built-in defaults and saving will not work.", errSave: "Save failed", errTimeout: "The database is not responding.", emptyHint: "Empty = there is no standard for this position.", brandTitle: "Hardware per brand", brandHint: "The brand chosen on the import page decides which hinges, plates and slides are prefilled. Empty = the standard hardware above is used.", extraTitle: "Extra articles", extraHint: "When an element of the PRO100 project contains the fragment, the article is added to the hardware with the element's count. Slides («Направляющие») are not affected by these rules: they are set in the «Hardware per brand» card.", extraMatch: "Fragment of the PRO100 name", extraMatchPh: "e.g. Kargo", extraAdd: "Add article", extraDel: "Delete", extraEmpty: "No extra articles yet.", colorsTitle: "Default colours", colorsHint: "The colour the import page puts on hardware rows of this kind; the base search filters by it. Empty = no colour.", colorOther: "Other accessories", colorNone: "—", colorPh: "e.g. Антрацит", }, ru: { title: "Стандарты", sub: "Значения по умолчанию, которые страница импорта подставляет автоматически.", hwTitle: "Стандартная фурнитура", matTitle: "Стандартные материалы", golaTitle: "Профиль Gola", svcTitle: "Стандартные цены услуг", code: "Код", name: "Название", clear: "Очистить", balamale: "Петли", placute: "Планки", picioruse: "Ножки", ghidaje: "Направляющие", spate: "Задняя стенка (ДВП)", gola: "Профиль Gola", golaH: "Высота профиля (мм)", golaHHint: "Используется для пересчёта м² → м.п. на странице импорта.", montare: "Сборка предустан.", livrare: "Доставка", proiectare: "Проектирование", tehnolog: "Технолог", transport: "Транспорт", svcHint: "Оставьте поле пустым, если у услуги нет стандартной цены.", save: "Сохранить", saving: "Сохранение…", saved: "Стандарты сохранены.", loading: "Загрузка…", dbDown: "База недоступна — показаны встроенные значения, сохранение не сработает.", errSave: "Не удалось сохранить", errTimeout: "База не отвечает.", emptyHint: "Пусто = стандарта для этой позиции нет.", brandTitle: "Фурнитура по брендам", brandHint: "Бренд, выбранный на странице импорта, решает, какие петли, планки и направляющие подставятся. Пусто = берётся стандартная фурнитура выше.", extraTitle: "Дополнительные артикулы", extraHint: "Если элемент проекта PRO100 содержит фрагмент, артикул попадёт в фурнитуру с количеством элемента. Элементы «Направляющие» эти правила не трогают: они задаются в карточке «Фурнитура по брендам».", extraMatch: "Фрагмент названия из PRO100", extraMatchPh: "напр. Kargo", extraAdd: "Добавить артикул", extraDel: "Удалить", extraEmpty: "Дополнительных артикулов пока нет.", colorsTitle: "Цвета по умолчанию", colorsHint: "Цвет, который страница импорта ставит строкам фурнитуры этого вида; поиск в базе фильтруется по нему. Пусто = без цвета.", colorOther: "Другая фурнитура", colorNone: "—", colorPh: "напр. Антрацит", }, }; const DB_CAT = window.DbCat; // Which picker category each standard searches. An empty string searches the // whole base: neither plates nor guides have a category of their own. const HW_FIELDS = [ { key: "balamale", category: DB_CAT.balamale }, { key: "placute", category: "" }, { key: "picioruse", category: DB_CAT.picioruse }, { key: "ghidaje", category: "" }, ]; // Kinds that differ per hardware brand (hw_brands in data/defaults.json). const BRAND_FIELDS = ["balamale", "placute", "ghidaje"]; // Same list as the import page's brand select; catalogs.js is not loaded here. const BRANDS = (window.CATALOGS && window.CATALOGS.FERONERIE_BRANDS) || ["Blum", "Hettich", "DTC"]; const EMPTY_REF = { code: "", name: "" }; /* Extra rows are keyed by a page-local id, so deleting one row does not hand its open picker to the next. The id never reaches the server. */ let extraSeq = 0; const withExtraIds = (list) => (list || []).map((x) => ({ ...x, _id: ++extraSeq })); /* Default colour per hardware kind (colors in data/defaults.json). The choices come from GET /api/colors for the category the kind's picker searches; plates follow the hinges and «other» covers everything else (IC.colorKindOf). */ const COLOR_FIELDS = [ { key: "balamale", label: "balamale", category: DB_CAT.balamale }, { key: "ghidaje", label: "ghidaje", category: "" }, { key: "gola", label: "gola", category: DB_CAT.gola }, { key: "other", label: "colorOther", category: "" }, ]; const MAT_FIELDS = [ { key: "spate", category: DB_CAT.spate }, { key: "gola", category: DB_CAT.gola }, ]; const SVC_FIELDS = [ { key: "montare", unit: "MDL/m²" }, { key: "livrare", unit: "MDL" }, { key: "proiectare", unit: "MDL" }, { key: "tehnolog", unit: "MDL/m²" }, { key: "transport", unit: "MDL" }, ]; /* One standard: code, name, a button that fills them from the base and a button that clears them. An empty pair is a valid standard — it means "no standard". */ function StandardRow({ t, lang, label, category, brand, fallbackCategory, value, onChange }) { const [modalOpen, setModalOpen] = useState(false); return (
{label}
onChange({ code: e.target.value, name: value.name })} />
onChange({ code: value.code, name: e.target.value })} />
setModalOpen(true)} /> {!value.code && !value.name && {t.emptyHint}}
{modalOpen && ( onChange({ code: it.code || "", name: it.name || it.code || "" })} onClose={() => setModalOpen(false)} /> )}
); } /* Hinges, plates and guides for one brand at a time. The picker is filtered by the brand and not by category: brands file the same article under different categories (DTC hinges sit in «Фурнитура»). */ function BrandCard({ t, lang, hwBrands, onChange }) { const [brand, setBrand] = useState(BRANDS.indexOf("Hettich") >= 0 ? "Hettich" : BRANDS[0]); const refs = hwBrands[brand] || {}; return (

{t.brandTitle}

{BRANDS.map((b) => ( ))}
{BRAND_FIELDS.map((k) => ( onChange(brand, k, pair)} /> ))}
{t.brandHint}
); } /* «PRO100 element name contains match → article». The article is picked from the whole base: an extra standard can be any accessory. */ function ExtraRow({ t, lang, value, onChange, onDelete }) { const [modalOpen, setModalOpen] = useState(false); return (
onChange({ ...value, match: e.target.value })} />
onChange({ ...value, code: e.target.value })} />
onChange({ ...value, name: e.target.value })} />
setModalOpen(true)} />
{modalOpen && ( onChange({ ...value, code: it.code || "", name: it.name || it.code || "" })} onClose={() => setModalOpen(false)} /> )}
); } function ExtraCard({ t, lang, extra, onChange }) { const update = (i, row) => onChange(extra.map((r, j) => (j === i ? row : r))); const remove = (i) => onChange(extra.filter((_, j) => j !== i)); const add = () => onChange(extra.concat([{ match: "", code: "", name: "", _id: ++extraSeq }])); return (

{t.extraTitle}

{extra.length === 0 &&
{t.extraEmpty}
} {extra.map((r, i) => ( update(i, row)} onDelete={() => remove(i)} /> ))}
{t.extraHint}
); } /* One select per kind, fed by the base's colours; a plain text input when the API is missing, the load failed or the category has no colours. */ function ColorsCard({ t, colors, onChange }) { const [opts, setOpts] = useState({}); // category -> [colours] | "error" useEffect(() => { if (!window.Api || !window.Api.colors) return undefined; let alive = true; const cats = []; COLOR_FIELDS.forEach((f) => { if (cats.indexOf(f.category) < 0) cats.push(f.category); }); cats.forEach((cat) => { window.Api.colors({ category: cat }).then( (list) => { if (alive) setOpts((o) => ({ ...o, [cat]: Array.isArray(list) ? list : [] })); }, () => { if (alive) setOpts((o) => ({ ...o, [cat]: "error" })); } ); }); return () => { alive = false; }; }, []); const hasApi = !!(window.Api && window.Api.colors); return (

{t.colorsTitle}

{COLOR_FIELDS.map((f) => { const cur = (colors && typeof colors[f.key] === "string") ? colors[f.key] : ""; const o = opts[f.category]; const list = Array.isArray(o) ? o : []; // Same spelling rule as the API filter: no case, no ё/е difference. const norm = (c) => String(c || "").trim().toLowerCase().replace(/ё/g, "е"); const listed = list.find((c) => norm(c) === norm(cur)); const shown = listed !== undefined ? listed : cur; const asText = !hasApi || o === "error" || (Array.isArray(o) && list.length === 0); return (
{asText ? ( onChange(f.key, e.target.value)} /> ) : ( )}
); })}
{t.colorsHint}
); } function StandardsApp() { const [lang, setLang] = useState("ro"); const t = I18N[lang]; const [std, setStd] = useState(() => { const d = IC.defaultsFallback(); return { ...d, extra: withExtraIds(d.extra) }; }); // The height is edited in millimetres and stored in metres, so the text the // manager is typing is kept as typed until it is saved. const [golaMm, setGolaMm] = useState(() => String(Math.round(IC.defaultsFallback().gola_h_m * 1000))); const [loading, setLoading] = useState(true); const [dbDown, setDbDown] = useState(false); const [saving, setSaving] = useState(false); const [toast, setToast] = useState(null); // { ok, text } const applyLoaded = (raw) => { const d = IC.mergeDefaults(raw); setStd({ ...d, extra: withExtraIds(d.extra) }); setGolaMm(String(Math.round(d.gola_h_m * 1000))); }; useEffect(() => { let alive = true; window.Api.defaults().then( (raw) => { if (!alive) return; applyLoaded(raw); setDbDown(false); setLoading(false); }, () => { if (!alive) return; setDbDown(true); setLoading(false); } ); return () => { alive = false; }; }, []); // Wire up the language switcher in the static header. useEffect(() => { const sw = document.getElementById("lang-switch"); if (!sw) return undefined; const onClick = (e) => { const b = e.target.closest("button[data-lang]"); if (!b) return; const newLang = b.dataset.lang; setLang(newLang); sw.querySelectorAll("button").forEach(x => x.classList.toggle("active", x.dataset.lang === newLang)); }; sw.addEventListener("click", onClick); return () => sw.removeEventListener("click", onClick); }, []); useEffect(() => { const sub = document.getElementById("sub-line"); if (sub) sub.textContent = t.sub; document.title = t.title + " — Furniture Bot"; }, [t]); useEffect(() => { if (!toast) return undefined; const timer = setTimeout(() => setToast(null), 4000); return () => clearTimeout(timer); }, [toast]); const setHw = (key, pair) => setStd((s) => ({ ...s, hw: { ...s.hw, [key]: pair } })); const setMat = (key, pair) => setStd((s) => ({ ...s, mat: { ...s.mat, [key]: pair } })); const setSvc = (key, v) => setStd((s) => ({ ...s, svc: { ...s.svc, [key]: v } })); const setBrandRef = (brand, key, pair) => setStd((s) => ({ ...s, hw_brands: { ...s.hw_brands, [brand]: { ...(s.hw_brands[brand] || {}), [key]: pair } }, })); const setExtra = (list) => setStd((s) => ({ ...s, extra: list })); const setColor = (key, v) => setStd((s) => ({ ...s, colors: { ...(s.colors || {}), [key]: v } })); const onSave = () => { const mm = Number(golaMm); // Every key goes back, including ones this page does not edit, so saving // never deletes a standard the server has and this page does not know. // An extra row without a fragment can never match: it is left out rather // than failing the whole save. const body = { ...std, gola_h_m: (isFinite(mm) && mm > 0) ? mm / 1000 : IC.GOLA_PROFILE_H_M, colors: Object.keys(std.colors || {}).reduce((o, k) => { o[k] = String(std.colors[k] || "").trim(); return o; }, {}), extra: (std.extra || []) .map(({ _id, ...x }) => ({ ...x, match: String(x.match || "").trim(), code: x.code || "", name: x.name || "" })) .filter((x) => x.match), }; setSaving(true); window.Api.saveDefaults(body).then( (saved) => { applyLoaded(saved || body); setDbDown(false); setSaving(false); setToast({ ok: true, text: t.saved }); }, (err) => { setSaving(false); const why = (err && err.code === "timeout") ? t.errTimeout : (err && err.message) || ""; setToast({ ok: false, text: why ? t.errSave + ": " + why : t.errSave }); } ); }; if (loading) return
{t.loading}
; return ( {dbDown &&
{t.dbDown}
}

{t.hwTitle}

{HW_FIELDS.map((f) => ( setHw(f.key, pair)} /> ))}

{t.matTitle}

{MAT_FIELDS.map((f) => ( setMat(f.key, pair)} /> ))}

{t.golaTitle}

setGolaMm(e.target.value)} />
{t.golaHHint}

{t.svcTitle}

{SVC_FIELDS.map((f) => (
setSvc(f.key, e.target.value === "" ? "" : Number(e.target.value))} /> {f.unit}
))}
{t.svcHint}
{toast &&
{toast.text}
}
); } const root = ReactDOM.createRoot(document.getElementById("root")); root.render();