/* picker.jsx — DbPicker: a modal that searches the live price base over
* GET /api/search (docs/api.md) and hands one position back to the caller.
*
* Babel compiles every `text/babel` script into a classic script, so all of them
* share one global lexical scope: a top-level `const` here would collide with
* the same name in a page script and kill both at parse time. Everything is
* therefore wrapped in an IIFE and published as window.DbPicker / window.PickerText.
*
* Styling reuses the modal classes already defined in each page's inline CSS
* (.modal-overlay, .modal, .modal-head, .modal-search, .modal-body, .modal-item,
* .empty-state), so the picker looks like the ArticleModal it replaces.
*/
(function () {
"use strict";
const { useState, useEffect, useRef } = React;
const MIN_CHARS = 2;
const DEBOUNCE_MS = 250;
const LIMIT = 25;
const TEXT = {
ro: {
title: "Caută în bază",
search: "Caută după denumire sau cod… (min. 2 caractere)",
minChars: "Scrie cel puțin 2 caractere.",
searching: "Se caută…",
noResults: "Nimic găsit.",
errTimeout: "Baza de date nu răspunde. Introduceți codul manual.",
errGeneric: "Eroare la căutare",
fromDb: "din bază",
clear: "Golește",
enterHint: "Enter alege primul rezultat.",
brandMissing: (b) => "Nu există articole " + b + " în bază — se arată toată baza.",
colorMissing: (c) => "Nu există articole în culoarea " + c + " — se arată toată selecția.",
},
en: {
title: "Search the database",
search: "Search by name or code… (min. 2 characters)",
minChars: "Type at least 2 characters.",
searching: "Searching…",
noResults: "Nothing found.",
errTimeout: "The database is not responding. Enter the code manually.",
errGeneric: "Search error",
fromDb: "from base",
clear: "Clear",
enterHint: "Enter picks the first result.",
brandMissing: (b) => "No " + b + " articles in the base — showing the whole base.",
colorMissing: (c) => "No articles in colour " + c + " — showing the whole selection.",
},
ru: {
title: "Поиск в базе",
search: "Поиск по названию или коду… (мин. 2 символа)",
minChars: "Введите не меньше 2 символов.",
searching: "Идёт поиск…",
noResults: "Ничего не найдено.",
errTimeout: "База не отвечает. Введите код вручную.",
errGeneric: "Ошибка поиска",
fromDb: "из базы",
clear: "Очистить",
enterHint: "Enter выбирает первый результат.",
brandMissing: (b) => "В базе нет позиций бренда " + b + " — показана вся база.",
colorMissing: (c) => "Нет позиций в цвете " + c + " — показана вся выборка.",
},
};
// Exact `category` values in data/*.json, as GET /api/search expects them.
const DB_CAT = {
corp: "ДСП",
fatade: "Фасадные плиты,Фасадные плиты PET/PVC,МДФ",
spate: "ДВП",
blat: "Столешки (Blat),Столешницы,HPL",
contrablat: "Фартуки (Briu),HPL",
gola: "Алюминиевый профиль,Фасады без ручек L+C,Профиль SLIM",
balamale: "Петли",
picioruse: "Ножки",
manere: "Ручки",
};
function textFor(lang) { return TEXT[lang] || TEXT.ro; }
function errText(err, t) {
if (err && err.code === "timeout") return t.errTimeout;
const msg = err && err.message ? String(err.message) : "";
return msg ? t.errGeneric + ": " + msg : t.errGeneric;
}
/* One result row: brand, name, code, price + unit, format. */
function ResultRow({ item, selected, onPick }) {
const meta = [item.brand, item.format].filter(function (x) { return x; }).join(" · ");
const price = (item.price === 0 || item.price) ? item.price + " MDL" : "";
const unit = item.unit ? " / " + item.unit : "";
return (
);
}
/* Props:
title optional heading, defaults to the localised "search the database"
category comma-separated list of exact category values (or an array)
file restrict to one data/*.json file
brand restrict to one brand (exact, case-insensitive on the file's brand);
when it finds nothing the picker says so and searches again
without the brand, under `fallbackCategory`
fallbackCategory category for that second search (default: none)
color restrict to one colour (docs/api.md «Цвет позиции»); when
nothing matches it the picker says so and searches again
without it (before it gives up on the brand)
lang "ro" | "en" | "ru"
currentCode highlights the row already chosen
onPick(item)
onClose() */
function DbPicker({ title, category, file, brand, fallbackCategory, color, lang, currentCode, onPick, onClose }) {
const t = textFor(lang);
const [q, setQ] = useState("");
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// The query `items` actually came back for. Without it a fresh query would
// read as "nothing found" during the debounce, and Enter could pick a row
// belonging to a query the manager has already typed past.
const [doneFor, setDoneFor] = useState(null);
// True when the brand had nothing and the list is the unfiltered fallback.
const [brandMissing, setBrandMissing] = useState(false);
// True when the colour had nothing and the list is shown without it.
const [colorMissing, setColorMissing] = useState(false);
const colorKey = String(color || "").trim();
// A slow answer to "ab" must never overwrite the answer to "abc".
const seq = useRef(0);
// The effect depends on a string, not on the prop: an inline array literal
// would be a fresh value every render and the effect would never settle.
const catKey = Array.isArray(category) ? category.join(",") : (category || "");
useEffect(() => {
const s = q.trim();
if (s.length < MIN_CHARS) {
seq.current += 1;
setItems([]); setError(""); setLoading(false); setDoneFor(null); setBrandMissing(false); setColorMissing(false);
return undefined;
}
const mine = ++seq.current;
const controller = typeof AbortController === "function" ? new AbortController() : null;
const timer = setTimeout(() => {
const api = window.Api;
if (!api) { setError(t.errGeneric); return; }
// The spinner belongs to a request that exists: during the debounce there
// is nothing in flight yet, and the previous results stay readable.
setLoading(true);
api.searchBrandFallback(s, {
category: catKey, file: file, brand: brand || undefined, color: colorKey || undefined, limit: LIMIT,
fallbackCategory: fallbackCategory || "",
signal: controller ? controller.signal : undefined,
}).then(
(res) => {
if (mine !== seq.current) return;
setItems(res.rows);
setBrandMissing(res.brandMissing);
setColorMissing(!!res.colorMissing);
setError(""); setLoading(false); setDoneFor(s);
},
(err) => {
// A superseded request was cancelled on purpose: say nothing.
if (err && err.code === "aborted") return;
if (mine !== seq.current) return;
setItems([]); setError(errText(err, t)); setLoading(false); setDoneFor(s); setBrandMissing(false); setColorMissing(false);
}
);
}, DEBOUNCE_MS);
// Leaving this query cancels both the pending debounce and the request it
// already started, so a stale answer never reaches the server twice over.
return () => { clearTimeout(timer); if (controller) controller.abort(); };
}, [q, catKey, file, brand, fallbackCategory, colorKey, lang]);
// Escape closes the picker from anywhere, not only from the search input.
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const pick = (it) => { onPick(it); onClose(); };
/* Enter picks implicitly, so it is only allowed while the list on screen is
the answer to what is currently typed. During the 250 ms debounce the
previous results stay visible, which keeps the list from flickering, and
clicking one of them is still fine — the manager chose that exact row.
Enter is not, because the first row is about to change under them. */
const current = doneFor !== null && doneFor === q.trim() && !loading ? items : [];
const onKeyDown = (e) => {
if (e.key === "Enter" && current.length > 0) { e.preventDefault(); pick(current[0]); }
};
return (
);
}
/* A small "from the database" button, so every call site looks the same. */
function DbPickButton({ lang, onClick, title }) {
const t = textFor(lang);
return (
);
}
window.DbCat = DB_CAT;
window.DbPicker = DbPicker;
window.DbPickButton = DbPickButton;
window.PickerText = TEXT;
})();