/* 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)} />
);
}
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 (
);
}
/* ---------------- 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. */}
{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 (
);
}
/* ---------------- 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 (
)}
);
}
/* 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();
})();