/* admin-app.jsx — the «Baza de date / База данных» page (ТЗ item 6). * * Everything the manager needs to keep the price base current without a * developer: browse the category files, search inside one, add, edit and delete * positions, create a new category file, correct the service prices and roll a * file back to one of its automatic backups. It is a thin shell over the bot's * HTTP API (docs/api.md) — no business logic lives here. * * Two rules the API imposes on this page, both load-bearing: * * - PUT /api/items REPLACES the whole position. The real files carry fields * this form never shows (model, color, dimensions, the legacy price_opt / * price_retail pair, …), so every save is built on top of `item.raw` and only * the edited keys are overwritten. Sending just the six visible fields would * silently strip the rest on the first edit. * - Indices shift when a position is deleted, so PUT and DELETE carry * `expect_code` — the code this page had on screen. A 409 means somebody else * changed the file and the list is reloaded instead of the write retried. * * Babel compiles every `text/babel` script into a classic script and they all * share one global lexical scope, so a top-level `const` here would collide with * the same name in picker.jsx and kill both at parse time. Hence the IIFE. */ (function () { "use strict"; const { useState, useEffect, useMemo, useCallback, useRef } = React; /* ---------------- i18n ---------------- */ const I18N = { ro: { title: "Baza de date", sub: "Categorii, poziții, prețuri de servicii și copii de siguranță.", tabFiles: "Categorii", tabServices: "Servicii", catsTitle: "Categorii", filter: "Filtrează după brand sau categorie…", noFiles: "Nicio categorie.", noFilesMatch: "Nicio categorie nu corespunde filtrului.", noBrand: "Fără brand", filesWord: "fișiere", newCat: "+ Categorie nouă", newCatTitle: "Categorie nouă", fCategory: "Categorie", fBrand: "Brand", fUnit: "Unitate", fFileName: "Nume fișier", fFileNameHint: "Doar litere latine mici, cifre și „_”. Se completează automat din categorie și brand.", create: "Creează", creating: "Se creează…", created: "Categoria a fost creată.", errFileName: "Numele fișierului trebuie să fie de forma nume_fisier (litere latine mici, cifre, „_”).", errCategoryReq: "Scrie categoria sau numele fișierului.", pickFile: "Alege o categorie din listă.", emptyFile: "Categoria nu are încă poziții.", searchItems: "Caută în această categorie…", noMatch: "Nicio poziție nu corespunde căutării.", add: "Adaugă", addTitle: "Poziție nouă", backupsBtn: "Copii", reloadBtn: "Reîncarcă", reloading: "Se reîncarcă…", reloaded: "Baza a fost recitită de pe disc.", positions: "poziții", colCode: "Cod", colName: "Denumire", colFormat: "Format", colColor: "Culoare", colPrice: "Preț", colUnit: "Unitate", colLength: "Lungime, m", unitOverride: "Unitate proprie", unitInherit: "Ca la categorie", lengthHint: "Gol = poziția nu e bară.", keepFields: "Câmpuri păstrate la salvare:", edit: "Modifică", del: "Șterge", save: "Salvează", saving: "Se salvează…", cancel: "Renunță", yes: "Da", no: "Nu", confirmDel: "Ștergi poziția?", added: "Poziția a fost adăugată.", updated: "Poziția a fost salvată.", deleted: "Poziția a fost ștearsă.", errNeedCodeOrName: "Scrie codul sau denumirea.", errPrice: "Prețul trebuie să fie un număr ≥ 0.", errLength: "Lungimea trebuie să fie un număr mai mare decât 0.", errPriceSvc: "Prețul trebuie să fie un număr ≥ 0 sau gol.", stale: "Poziția s-a schimbat, reîmprospătează lista.", backupsTitle: "Copii de siguranță", noBackups: "Nu există copii pentru acest fișier.", restore: "Restaurează", confirmRestore: "Restaurezi această copie?", restored: "Fișierul a fost restaurat.", close: "Închide", svcTitle: "Prețuri servicii", svcFilter: "Caută un serviciu…", svcNoMatch: "Niciun serviciu nu corespunde căutării.", svcEmpty: "Nu sunt servicii.", priceVip: "Preț VIP", priceRetail: "Preț retail", svcSaved: "Prețul serviciului a fost salvat.", loading: "Se încarcă…", dbDown: "Baza de date nu răspunde. Reîncarcă pagina sau încearcă mai târziu.", broken: "Fișiere care nu se citesc și sunt ignorate:", errLoad: "Încărcarea a eșuat", errSave: "Salvarea a eșuat", errTimeout: "Baza de date nu răspunde.", retry: "Încearcă din nou", }, ru: { title: "База данных", sub: "Категории, позиции, цены услуг и резервные копии.", tabFiles: "Категории", tabServices: "Услуги", catsTitle: "Категории", filter: "Фильтр по бренду или категории…", noFiles: "Категорий нет.", noFilesMatch: "Под фильтр ничего не подошло.", noBrand: "Без бренда", filesWord: "файлов", newCat: "+ Новая категория", newCatTitle: "Новая категория", fCategory: "Категория", fBrand: "Бренд", fUnit: "Единица", fFileName: "Имя файла", fFileNameHint: "Только строчные латинские буквы, цифры и «_». Подставляется из категории и бренда.", create: "Создать", creating: "Создаём…", created: "Категория создана.", errFileName: "Имя файла — вида imya_fayla (строчные латинские буквы, цифры, «_»).", errCategoryReq: "Укажите категорию или имя файла.", pickFile: "Выберите категорию в списке.", emptyFile: "В категории пока нет позиций.", searchItems: "Поиск в этой категории…", noMatch: "Под запрос ничего не подошло.", add: "Добавить", addTitle: "Новая позиция", backupsBtn: "Копии", reloadBtn: "Перечитать", reloading: "Перечитываем…", reloaded: "База перечитана с диска.", positions: "позиций", colCode: "Код", colName: "Название", colFormat: "Формат", colColor: "Цвет", colPrice: "Цена", colUnit: "Единица", colLength: "Длина, м", unitOverride: "Своя единица", unitInherit: "Как у категории", lengthHint: "Пусто = позиция не палка.", keepFields: "Поля, которые сохранятся как есть:", edit: "Изменить", del: "Удалить", save: "Сохранить", saving: "Сохранение…", cancel: "Отмена", yes: "Да", no: "Нет", confirmDel: "Удалить позицию?", added: "Позиция добавлена.", updated: "Позиция сохранена.", deleted: "Позиция удалена.", errNeedCodeOrName: "Укажите код или название.", errPrice: "Цена — число ≥ 0.", errLength: "Длина — число больше 0.", errPriceSvc: "Цена — число ≥ 0 или пусто.", stale: "Позиция изменилась, обновите список.", backupsTitle: "Резервные копии", noBackups: "Копий этого файла нет.", restore: "Восстановить", confirmRestore: "Восстановить эту копию?", restored: "Файл восстановлен.", close: "Закрыть", svcTitle: "Цены услуг", svcFilter: "Поиск услуги…", svcNoMatch: "Под запрос ничего не подошло.", svcEmpty: "Нет услуг.", priceVip: "Цена ВИП", priceRetail: "Цена розница", svcSaved: "Цена услуги сохранена.", loading: "Загрузка…", dbDown: "База не отвечает. Обновите страницу или попробуйте позже.", broken: "Файлы, которые не читаются и пропущены:", errLoad: "Не удалось загрузить", errSave: "Не удалось сохранить", errTimeout: "База не отвечает.", retry: "Повторить", }, }; // The units a category file can be priced in (ТЗ stage 1): square metres, // linear metres and pieces. `mp` is the linear metre, `m2` the square one. const UNITS = ["m2", "mp", "buc"]; // The keys this form edits by hand. Everything else in a position is carried // over untouched on save and listed under the editor so it is visibly kept. const EDITED_KEYS = ["code", "name", "format", "color", "price", "unit", "length_m"]; // Some files describe a position without ever giving it a code or a name: // every row of data/abs_edge.json is a category, a size and a note. Those rows // are still real positions the manager must be able to price, so these keys // both stand in for the name in the table and let such a row be saved. const DESCRIPTIVE_KEYS = ["category", "size", "decor", "model", "note"]; /* ---------------- helpers ---------------- */ function errText(err, t, prefix) { if (err && err.code === "timeout") return t.errTimeout; const msg = err && err.message ? String(err.message) : ""; return msg ? prefix + ": " + msg : prefix; } function isStale(err) { return !!(err && err.status === 409); } /* Cyrillic and Romanian letters → latin, so a category called «Петли» can still produce a file name the server accepts: storage.check_filename only allows ^[a-z0-9_]+\.json$. */ const TRANSLIT = { а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z", и: "i", й: "i", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "c", ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya", ă: "a", â: "a", î: "i", ș: "s", ț: "t", ş: "s", ţ: "t", á: "a", é: "e", í: "i", ó: "o", ú: "u", ü: "u", ö: "o", ä: "a", ß: "ss", }; const STEM_RE = /^[a-z0-9_]+$/; function slugify(text) { let out = ""; const lower = String(text === undefined || text === null ? "" : text).toLowerCase(); for (let i = 0; i < lower.length; i += 1) { const ch = lower.charAt(i); out += TRANSLIT[ch] !== undefined ? TRANSLIT[ch] : ch; } return out.replace(/[^a-z0-9]+/g, "_").replace(/^_+/, "").replace(/_+$/, ""); } /* The proposed file name. It is only a proposal: the field stays editable and is validated on its own, because a category written in a script this table does not cover would otherwise slug down to nothing. */ function deriveFileName(category, brand) { const parts = [slugify(category), slugify(brand)].filter(function (x) { return x; }); return parts.join("_"); } /* A price as the manager types it: a comma is a decimal point here. */ function parsePrice(text) { const s = String(text === undefined || text === null ? "" : text).trim().replace(/,/g, "."); if (s === "") return { ok: false }; const n = Number(s); if (!isFinite(n) || n < 0) return { ok: false }; return { ok: true, value: n }; } /* An optional bar length. Empty is valid and means "not a bar", so the key is removed from the position rather than written as 0. */ function parseLength(text) { const s = String(text === undefined || text === null ? "" : text).trim().replace(/,/g, "."); if (s === "") return { ok: true, value: null }; const n = Number(s); if (!isFinite(n) || n <= 0) return { ok: false }; return { ok: true, value: n }; } function asText(value) { return value === undefined || value === null ? "" : String(value); } /* The editor's fields for one position, read off `raw` — the position exactly as it sits in the file. `price` is the one field read off the computed top-level value instead: a row that only carries the legacy `price_opt` has no `raw.price`, and what the bot charges for it is the computed one. */ function editorFrom(row) { const raw = (row && row.raw) || {}; const price = raw.price !== undefined ? raw.price : (row && (row.price === 0 || row.price) ? row.price : ""); return { code: asText(raw.code), name: asText(raw.name), format: asText(raw.format), color: asText(raw.color), price: asText(price), unit: asText(raw.unit), length_m: asText(raw.length_m), }; } function emptyEditor() { return { code: "", name: "", format: "", color: "", price: "", unit: "", length_m: "" }; } /* Validate the form and build the PUT/POST body on top of `raw`. Returns either { error } or { item }. */ function buildItem(raw, ed, t) { const code = ed.code.trim(); const name = ed.name.trim(); if (!code && !name && !selfDescribing(raw)) return { error: t.errNeedCodeOrName }; const price = parsePrice(ed.price); if (!price.ok) return { error: t.errPrice }; const length = parseLength(ed.length_m); if (!length.ok) return { error: t.errLength }; // Start from the row on disk so untouched fields survive the replacement. const item = {}; Object.keys(raw || {}).forEach(function (key) { if (key.charAt(0) !== "_") item[key] = raw[key]; }); const put = function (key, value) { if (value === "") delete item[key]; else item[key] = value; }; put("code", code); put("name", name); put("format", ed.format.trim()); // Like format: an empty colour removes the key rather than storing "". put("color", String(ed.color === undefined || ed.color === null ? "" : ed.color).trim()); put("unit", ed.unit.trim()); item.price = price.value; if (length.value === null) delete item.length_m; else item.length_m = length.value; return { item: item }; } /* The descriptive fields a row carries, in a fixed order, as [key, text]. */ function descriptiveParts(row) { const raw = (row && row.raw) || {}; const parts = []; DESCRIPTIVE_KEYS.forEach(function (key) { const v = raw[key]; if (v === null || v === undefined || typeof v === "object") return; const text = String(v).trim(); if (text) parts.push([key, text]); }); return parts; } /* A stand-in name for a row that has none, e.g. «Кромка ABS · 22x0.8 · под ДСП». Empty when the row describes itself no other way. */ function describeRow(row) { return descriptiveParts(row).map(function (p) { return p[1]; }).join(" · "); } /* Whether a position is identifiable without a code and without a name. The server accepts such a row only when it says something else about itself, and so does this form — but never for a brand-new position, where an empty row would just be a mistake. */ function selfDescribing(raw) { return descriptiveParts({ raw: raw }).length > 0; } /* Fields of the position that this form does not show but does keep. `skip` names the ones already on screen as the row's stand-in label, so they are not repeated underneath it. */ function keptKeys(row, skip) { const raw = (row && row.raw) || {}; const hidden = skip || []; return Object.keys(raw).filter(function (key) { return key.charAt(0) !== "_" && EDITED_KEYS.indexOf(key) === -1 && hidden.indexOf(key) === -1; }); } /* What a client-side search inside one file matches on: the visible columns plus whatever else the row carries, since some files describe a position only through fields like `size` or `note`. */ function haystack(row) { const raw = (row && row.raw) || {}; const parts = []; Object.keys(raw).forEach(function (key) { if (key.charAt(0) === "_") return; const v = raw[key]; if (v === null || v === undefined || typeof v === "object") return; parts.push(String(v)); }); return parts.join(" ").toLowerCase(); } /* ---------------- small pieces ---------------- */ /* An inline yes/no. The app never opens a browser dialog: inside Telegram a window.confirm is either suppressed or looks like a browser error. */ function InlineConfirm({ t, question, onYes, onNo, busy }) { return ( {question} ); } function Toast({ toast }) { if (!toast) return null; return
{toast.text}
; } /* ---------------- brand grouping ---------------- */ // The list has ~80 files with repeating category names (five «ДСП», five // «Ручки», one file per Hettich drawer model), so it is shown Brand → files. // Grouping is presentation only: the key is the trimmed, lower-cased brand so // "Egger" and "EGGER " land together, and the first spelling seen is shown. // Files without a brand go into one group that always sorts last. function brandKey(brand) { return String(brand == null ? "" : brand).trim().toLowerCase(); } function byText(a, b) { return a.localeCompare(b, "ru", { sensitivity: "base" }); } function groupFilesByBrand(files) { const groups = new Map(); (files || []).forEach(function (f) { const key = brandKey(f.brand); let g = groups.get(key); if (!g) { g = { key: key, brand: String(f.brand || "").trim(), files: [], count: 0 }; groups.set(key, g); } g.files.push(f); g.count += Number(f.count) || 0; }); const list = Array.from(groups.values()); list.forEach(function (g) { g.files.sort(function (a, b) { return byText(a.category || a.file, b.category || b.file) || byText(a.file, b.file); }); }); list.sort(function (a, b) { if (!a.key !== !b.key) return a.key ? -1 : 1; return byText(a.key, b.key); }); return list; } // Narrow the groups to a text filter. A brand that matches keeps all its // files; otherwise only the files whose category or file name match stay. // Counts are recomputed so the brand row describes what is on screen. function filterBrandGroups(groups, filter) { const q = String(filter || "").trim().toLowerCase(); if (!q) return groups; const out = []; groups.forEach(function (g) { const files = g.brand.toLowerCase().indexOf(q) !== -1 ? g.files : g.files.filter(function (f) { return ((f.category || "") + " " + (f.unit || "") + " " + f.file).toLowerCase().indexOf(q) !== -1; }); if (!files.length) return; out.push({ key: g.key, brand: g.brand, files: files, count: files.reduce(function (n, f) { return n + (Number(f.count) || 0); }, 0), }); }); return out; } // Which brand groups are open survives a reload. Storage can be missing or // throw (private mode, Telegram WebView quirks), so it is strictly best-effort. const EXPANDED_KEY = "baza.expandedBrands"; function loadExpanded() { try { const v = JSON.parse(window.localStorage.getItem(EXPANDED_KEY) || "[]"); return Array.isArray(v) ? v.filter(function (x) { return typeof x === "string"; }) : []; } catch (e) { return []; } } function saveExpanded(keys) { try { window.localStorage.setItem(EXPANDED_KEY, JSON.stringify(keys)); } catch (e) { /* ignore */ } } /* ---------------- the category list ---------------- */ function NewFileForm({ t, brands, busy, onCreate, onCancel }) { const [category, setCategory] = useState(""); const [brand, setBrand] = useState(""); const [unit, setUnit] = useState(UNITS[0]); // The name follows the category until the manager edits it by hand, after // which it is theirs and must not be overwritten under their cursor. const [stem, setStem] = useState(""); const [stemTouched, setStemTouched] = useState(false); const [error, setError] = useState(""); const proposed = stemTouched ? stem : deriveFileName(category, brand); const submit = () => { const clean = proposed.trim(); if (!clean) { setError(category.trim() ? t.errFileName : t.errCategoryReq); return; } if (!STEM_RE.test(clean)) { setError(t.errFileName); return; } setError(""); onCreate({ file: clean + ".json", brand: brand.trim(), category: category.trim(), unit: unit, }); }; return (
{t.newCatTitle}
setCategory(e.target.value)} />
{/* Offer the existing brands so a new file joins its group instead of starting a near-duplicate spelling. */} setBrand(e.target.value)} /> {(brands || []).map((b) =>
{ setStemTouched(true); setStem(e.target.value); setError(""); }} /> {t.fFileNameHint}
{error &&
{error}
}
); } function FileList({ t, files, selected, onSelect, creating, onCreate }) { const [filter, setFilter] = useState(""); const [formOpen, setFormOpen] = useState(false); const [expanded, setExpanded] = useState(loadExpanded); const groups = useMemo(() => groupFilesByBrand(files), [files]); const filtering = filter.trim() !== ""; const shown = useMemo(() => filterBrandGroups(groups, filter), [groups, filter]); const brandNames = useMemo( () => groups.filter((g) => g.key).map((g) => g.brand), [groups] ); // The selected file's group opens whenever the selection changes (a click, // or a freshly created file), so the active row is never hidden. const selectedKey = useMemo(() => { const f = files.find((x) => x.file === selected); return f ? brandKey(f.brand) : null; }, [files, selected]); useEffect(() => { if (selectedKey === null) return; setExpanded((prev) => { if (prev.indexOf(selectedKey) !== -1) return prev; const next = prev.concat([selectedKey]); saveExpanded(next); return next; }); }, [selectedKey]); const toggle = (key) => { setExpanded((prev) => { const next = prev.indexOf(key) !== -1 ? prev.filter((k) => k !== key) : prev.concat([key]); saveExpanded(next); return next; }); }; return (
{t.catsTitle} {files.length}
setFilter(e.target.value)} />
{shown.length === 0 && (
{files.length === 0 ? t.noFiles : t.noFilesMatch}
)} {shown.map((g) => { // While filtering every matching group is open, so a hit is never // hidden behind a collapsed brand. const open = filtering || expanded.indexOf(g.key) !== -1; return (
{open && (
{g.files.map((f) => ( ))}
)}
); })}
{!formOpen && (
)} {formOpen && ( setFormOpen(false)} onCreate={(spec) => onCreate(spec, () => setFormOpen(false))} /> )}
); } /* ---------------- one position ---------------- */ /* The add/edit form. `row` is null when adding. Validation is inline: nothing is sent until the fields parse. */ function ItemEditor({ t, row, fileUnit, colors, busy, onSave, onCancel }) { const [ed, setEd] = useState(() => (row ? editorFrom(row) : emptyEditor())); const [error, setError] = useState(""); const set = (key) => (e) => { setEd(Object.assign({}, ed, { [key]: e.target.value })); setError(""); }; const kept = row ? keptKeys(row) : []; const submit = () => { const built = buildItem(row ? row.raw : {}, ed, t); if (built.error) { setError(built.error); return; } onSave(built.item); }; const badPrice = !!error && error === t.errPrice; const badLength = !!error && error === t.errLength; const badId = !!error && error === t.errNeedCodeOrName; return (
{row ? t.edit : t.addTitle}
{/* Free text, with this file's colours offered so the same colour is spelled the same way: the order pages filter on it exactly. */} {(colors || []).map((c) =>
{t.lengthHint}
{kept.length > 0 && (
{t.keepFields} {kept.join(", ")}
)} {error &&
{error}
}
); } function ItemRow({ t, row, showLength, busy, onEdit, onDelete }) { const [confirming, setConfirming] = useState(false); // A row with neither code nor name still has to be recognisable, so what it // does say about itself stands in for the name. const described = row.name ? "" : describeRow(row); // When the label is standing in for the name it speaks for the whole // descriptive family, including a member that happens to be blank: listing // a bare «note» underneath would say nothing the manager can act on. const usedForLabel = described ? DESCRIPTIVE_KEYS : []; const kept = keptKeys(row, usedForLabel); return (
{t.colCode} {row.code || "—"}
{t.colName} {row.name || described || "—"} {row.color && ( {row.color} )} {kept.length > 0 &&
{kept.join(" · ")}
}
{t.colFormat} {row.format || "—"}
{t.colPrice} {row.price === 0 || row.price ? row.price : "—"}
{t.colUnit} {row.unit || "—"}
{showLength && (
{t.colLength} {row.length_m === 0 || row.length_m ? row.length_m : "—"}
)}
{confirming ? ( onDelete(row)} onNo={() => setConfirming(false)} /> ) : ( )}
); } /* ---------------- backups ---------------- */ function BackupPanel({ t, file, busy, onRestore, onClose }) { const [list, setList] = useState(null); const [error, setError] = useState(""); const [confirming, setConfirming] = useState(""); useEffect(() => { let alive = true; setList(null); setError(""); setConfirming(""); window.Api.backups(file).then( (rows) => { if (alive) setList(Array.isArray(rows) ? rows : []); }, (err) => { if (alive) { setList([]); setError(errText(err, t, t.errLoad)); } } ); return () => { alive = false; }; }, [file]); return (
{t.backupsTitle}
{error &&
{error}
} {list === null &&
{t.loading}
} {list !== null && list.length === 0 && !error &&
{t.noBackups}
} {list !== null && list.length > 0 && (
{list.map((name) => (
{name} {confirming === name ? ( onRestore(name)} onNo={() => setConfirming("")} /> ) : ( )}
))}
)}
); } /* ---------------- services ---------------- */ /* One service row. The prices are the only editable part: the categories and names come from the supplier's price list. */ function ServiceRow({ t, row, busy, onSave }) { const [vip, setVip] = useState(asText(row.price_vip)); const [retail, setRetail] = useState(asText(row.price_retail)); const [error, setError] = useState(""); // A row refreshed from the server replaces whatever was typed into it. useEffect(() => { setVip(asText(row.price_vip)); setRetail(asText(row.price_retail)); setError(""); }, [row.price_vip, row.price_retail]); const dirty = vip !== asText(row.price_vip) || retail !== asText(row.price_retail); // An empty price is allowed and stored as null: not every service has both. const check = (text) => (text.trim() === "" ? { ok: true, value: "" } : parsePrice(text)); const submit = () => { const a = check(vip); const b = check(retail); if (!a.ok || !b.ok) { setError(t.errPriceSvc); return; } setError(""); onSave(row.index, { price_vip: a.value, price_retail: b.value }); }; return (
{row.name || "—"}
{[row.category, row.unit].filter(function (x) { return x; }).join(" · ")}
{ setVip(e.target.value); setError(""); }} />
{ setRetail(e.target.value); setError(""); }} />
{error &&
{error}
}
); } function ServicesTab({ t, rows, loading, error, busy, onSave, onReload }) { const [filter, setFilter] = useState(""); const shown = useMemo(() => { const q = filter.trim().toLowerCase(); if (!q) return rows; return rows.filter(function (r) { return ((r.category || "") + " " + (r.name || "") + " " + (r.unit || "")).toLowerCase().indexOf(q) !== -1; }); }, [rows, filter]); return (
{t.svcTitle} {rows.length}
{error && (
{error}
)} setFilter(e.target.value)} /> {loading &&
{t.loading}
} {!loading && shown.length === 0 && !error && (
{rows.length === 0 ? t.svcEmpty : t.svcNoMatch}
)}
{shown.map((r) => ( ))}
); } /* ---------------- the page ---------------- */ function AdminApp() { const [lang, setLang] = useState("ro"); const t = I18N[lang]; const [tab, setTab] = useState("files"); const [toast, setToast] = useState(null); const [busy, setBusy] = useState(false); // Separate from `busy` only so the reload button's own label is honest while // some other write is in flight. const [reloading, setReloading] = useState(false); const [creatingFile, setCreatingFile] = useState(false); const [health, setHealth] = useState(null); const [dbDown, setDbDown] = useState(false); const [files, setFiles] = useState([]); const [filesLoading, setFilesLoading] = useState(true); const [selected, setSelected] = useState(""); const [rows, setRows] = useState([]); // Colours already used in the selected file — suggestions for the editor. const [colors, setColors] = useState([]); const [rowsLoading, setRowsLoading] = useState(false); const [rowsError, setRowsError] = useState(""); const [query, setQuery] = useState(""); const [editing, setEditing] = useState(null); // { mode: "add" } | { mode: "edit", index } const [showBackups, setShowBackups] = useState(false); const [services, setServices] = useState([]); const [svcLoading, setSvcLoading] = useState(false); const [svcError, setSvcError] = useState(""); const say = useCallback((ok, text) => setToast({ ok: ok, text: text }), []); useEffect(() => { if (!toast) return undefined; const timer = setTimeout(() => setToast(null), 4000); return () => clearTimeout(timer); }, [toast]); /* --- loaders --- */ const loadHealth = useCallback(() => { window.Api.health().then( (h) => { setHealth(h); setDbDown(false); }, () => { setHealth(null); setDbDown(true); } ); }, []); const loadFiles = useCallback(() => { setFilesLoading(true); return window.Api.files().then( (list) => { setFiles(Array.isArray(list) ? list : []); setFilesLoading(false); setDbDown(false); }, (err) => { setFiles([]); setFilesLoading(false); setDbDown(true); return Promise.reject(err); } ); }, []); // `alive` is a ref rather than a closure variable so a reload triggered from // a button uses the same guard as the one triggered by selecting a file. const rowsSeq = useRef(0); const loadRows = useCallback((file) => { if (!file) { setRows([]); setRowsError(""); return Promise.resolve(); } const mine = ++rowsSeq.current; setRowsLoading(true); setRowsError(""); return window.Api.items(file).then( (list) => { if (mine !== rowsSeq.current) return; setRows(Array.isArray(list) ? list : []); setRowsLoading(false); }, (err) => { if (mine !== rowsSeq.current) return; setRows([]); setRowsLoading(false); setRowsError(errText(err, t, t.errLoad)); } ); }, [t]); const colorsSeq = useRef(0); const loadColors = useCallback((file) => { const mine = ++colorsSeq.current; if (!file || !window.Api.colors) { setColors([]); return; } // Only suggestions: a failure leaves the list empty and the field still free text. window.Api.colors({ file: file }).then( (list) => { if (mine === colorsSeq.current) setColors(Array.isArray(list) ? list : []); }, () => { if (mine === colorsSeq.current) setColors([]); } ); }, []); const loadServices = useCallback(() => { setSvcLoading(true); setSvcError(""); return window.Api.services().then( (list) => { setServices(Array.isArray(list) ? list : []); setSvcLoading(false); }, (err) => { setServices([]); setSvcLoading(false); setSvcError(errText(err, t, t.errLoad)); } ); }, [t]); useEffect(() => { loadHealth(); loadFiles().catch(function () {}); }, [loadHealth, loadFiles]); useEffect(() => { loadRows(selected); loadColors(selected); }, [selected]); useEffect(() => { if (tab === "services" && services.length === 0 && !svcLoading) loadServices(); }, [tab]); /* --- header wiring, same pattern as the standards page --- */ 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 next = b.dataset.lang; setLang(next); sw.querySelectorAll("button").forEach(function (x) { x.classList.toggle("active", x.dataset.lang === next); }); }; sw.addEventListener("click", onClick); return () => sw.removeEventListener("click", onClick); }, []); useEffect(() => { const sub = document.getElementById("sub-line"); if (sub) sub.textContent = t.sub; const head = document.getElementById("head-title"); if (head) head.textContent = t.title; document.title = t.title + " — Furniture Bot"; }, [t]); /* --- writes --- */ /* Every write ends the same way: report it, refresh the file it touched and refresh the counts in the category list. A 409 is not a failure of the request but a stale list, so it reloads instead of asking again. */ const afterWrite = (message) => { say(true, message); // Indices shift after a delete or a restore, so an editor still open here // would come back attached to whatever now sits at that index. setEditing(null); loadRows(selected); loadColors(selected); loadFiles().catch(function () {}); loadHealth(); }; const onWriteError = (err) => { if (isStale(err)) { say(false, t.stale); loadRows(selected); return; } say(false, errText(err, t, t.errSave)); }; const createFile = (spec, done) => { setBusy(true); setCreatingFile(true); window.Api.createFile(spec).then( () => { setBusy(false); setCreatingFile(false); done(); say(true, t.created); // Select the new file straight away: it is empty, so the manager's next // move is always to add the first position to it. setSelected(spec.file); setQuery(""); setEditing(null); setShowBackups(false); loadFiles().catch(function () {}); loadHealth(); }, (err) => { setBusy(false); setCreatingFile(false); say(false, errText(err, t, t.errSave)); } ); }; const addItem = (item) => { setBusy(true); window.Api.addItem(selected, item).then( () => { setBusy(false); setEditing(null); afterWrite(t.added); }, // The editor stays open on failure, so a rejected position can be // corrected instead of retyped. (err) => { setBusy(false); onWriteError(err); } ); }; const updateItem = (row, item) => { setBusy(true); window.Api.updateItem(selected, row.index, item, row.code, row.raw).then( () => { setBusy(false); setEditing(null); afterWrite(t.updated); }, (err) => { setBusy(false); // A 409 means the list is stale and is about to be reloaded, so the // editor has nothing left to edit. Every other failure — a timeout, a // rejected field, a 500 — leaves the typed row on screen: closing the // editor would throw the manager's work away and leave them a toast. if (isStale(err)) setEditing(null); onWriteError(err); } ); }; const deleteItem = (row) => { setBusy(true); window.Api.deleteItem(selected, row.index, row.code, row.raw).then( () => { setBusy(false); afterWrite(t.deleted); }, (err) => { setBusy(false); onWriteError(err); } ); }; const restore = (backup) => { setBusy(true); window.Api.restore(selected, backup).then( () => { setBusy(false); setShowBackups(false); afterWrite(t.restored); }, (err) => { setBusy(false); say(false, errText(err, t, t.errSave)); } ); }; const saveService = (index, patch) => { setBusy(true); window.Api.saveService(index, patch).then( (saved) => { setBusy(false); say(true, t.svcSaved); setServices(function (list) { return list.map(function (r) { return r.index === index ? Object.assign({}, r, saved, { index: index }) : r; }); }); }, (err) => { setBusy(false); say(false, errText(err, t, t.errSave)); } ); }; const reloadAll = () => { setBusy(true); setReloading(true); window.Api.reload().then( () => { setBusy(false); setReloading(false); say(true, t.reloaded); // Same reason as in afterWrite: the list under the editor is replaced. setEditing(null); loadFiles().catch(function () {}); loadRows(selected); loadHealth(); if (tab === "services") loadServices(); }, (err) => { setBusy(false); setReloading(false); say(false, errText(err, t, t.errSave)); } ); }; /* --- derived --- */ const current = useMemo( () => files.filter(function (f) { return f.file === selected; })[0] || null, [files, selected] ); // The row being edited stays in the list whatever the search says: the editor // lives inside this list, so filtering it out mid-edit would unmount it and // take the half-typed position with it. const editingIndex = editing && editing.mode === "edit" ? editing.index : null; const shownRows = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return rows; return rows.filter(function (r) { return r.index === editingIndex || haystack(r).indexOf(q) !== -1; }); }, [rows, query, editingIndex]); // The length column only exists for files that actually carry bars. const showLength = useMemo( () => rows.some(function (r) { return r.length_m !== undefined && r.length_m !== null; }), [rows] ); /* The row grid's columns, and the width below which they stop being readable. The fixed tracks plus the gaps come to 646 px with a length column and 578 px without; `--it-min` adds ~200 px for the name, which is the only track that flexes. Without a floor the name collapses to nothing and the header labels print on top of each other. Baza.html scrolls the list inside its own card when the card is narrower than this. */ const colStyle = { "--it-cols": showLength ? "108px minmax(0, 1fr) 96px 84px 64px 84px 150px" : "108px minmax(0, 1fr) 110px 90px 70px 150px", "--it-min": showLength ? "846px" : "778px", }; const broken = (health && health.broken_files) || []; return ( {dbDown &&
{t.dbDown}
} {broken.length > 0 && (
{t.broken} {broken.map(function (b) { return {b} ; })}
)}
{health && ( {health.items} {t.positions} · {health.files} {t.tabFiles.toLowerCase()} )}
{tab === "services" && ( )} {tab === "files" && (
{ setSelected(f); setQuery(""); setEditing(null); setShowBackups(false); }} creating={creatingFile} onCreate={createFile} />
{!selected && (
{filesLoading ? t.loading : t.pickFile}
)} {selected && (
{current ? (current.category || current.file) : selected} {current && current.brand && {current.brand}} {current && current.unit && {current.unit}} {rows.length} {t.positions}
setQuery(e.target.value)} />
{rowsError &&
{rowsError}
}
{!rowsLoading && rows.length > 0 && (
{t.colCode}
{t.colName}
{t.colFormat}
{t.colPrice}
{t.colUnit}
{showLength &&
{t.colLength}
}
)} {editing && editing.mode === "add" && ( setEditing(null)} /> )} {rowsLoading &&
{t.loading}
} {!rowsLoading && !rowsError && shownRows.length === 0 && (
{rows.length === 0 ? t.emptyFile : t.noMatch}
)} {!rowsLoading && shownRows.map(function (r) { if (editing && editing.mode === "edit" && editing.index === r.index) { return ( updateItem(r, item)} onCancel={() => setEditing(null)} /> ); } return ( setEditing({ mode: "edit", index: row.index })} onDelete={deleteItem} /> ); })}
)} {selected && showBackups && ( setShowBackups(false)} /> )}
)} ); } /* The pure half of this file — the parts that decide what gets written to a price file — is published so it can be unit tested without a DOM. Nothing in the page reads it back; see tests/admin_core.test.mjs. */ window.AdminCore = { UNITS: UNITS, EDITED_KEYS: EDITED_KEYS, STEM_RE: STEM_RE, slugify: slugify, deriveFileName: deriveFileName, parsePrice: parsePrice, parseLength: parseLength, editorFrom: editorFrom, emptyEditor: emptyEditor, buildItem: buildItem, keptKeys: keptKeys, DESCRIPTIVE_KEYS: DESCRIPTIVE_KEYS, descriptiveParts: descriptiveParts, describeRow: describeRow, selfDescribing: selfDescribing, haystack: haystack, isStale: isStale, brandKey: brandKey, groupFilesByBrand: groupFilesByBrand, filterBrandGroups: filterBrandGroups, }; const adminRoot = ReactDOM.createRoot(document.getElementById("root")); adminRoot.render(); })();