);
}
/* ---------- 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 (
);
}
/* 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 (
);
}
/* 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 (
);
}
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. */}
{/* 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. */}
);
}
/* ---------- 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 (
);
}
/* ---------- 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 (
{
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. */}
);
}
function App() {
const [lang, setLang] = useState("ro");
const t = I18N[lang];
const [raw, setRaw] = useState("");
const [parsed, setParsed] = useState(null);
const [error, setError] = useState("");
// corp, fatade and the countertop are lists of models, see newModel.
const [corpuri, setCorpuri] = useState(() => [newModel({ brand: "", code: "", name: "", price: 0 })]);
const [fatade, setFatade] = useState(() => [newModel({ type: "", brand: "", code: "", name: "", price: 0 })]);
const [blat, setBlat] = useState(() => ({
blat: [{ ...newModel({ type: "", brand: "", code: "", name: "", price: 0 }), w: 600 }],
contrablat: { type: "", brand: "", code: "", name: "", price: 0 },
}));
const [contrablatLen, setContrablatLen] = useState("");
// True once the manager picked a contrablat themselves, as opposed to the one
// the blat picker copies over for them.
const [contrablatTouched, setContrablatTouched] = useState(false);
const [spate, setSpate] = useState(() => ({ ...STD_FALLBACK.mat.spate, unit: "", price: undefined }));
const [gola, setGola] = useState(() => ({
mp: "", ...STD_FALLBACK.mat.gola, unit: "", price: undefined, color: IC.colorFor(STD_FALLBACK.colors, "gola"),
}));
const [feronerie, setFeronerie] = useState("");
const [hw, setHw] = useState([]);
const [placuteEdited, setPlacuteEdited] = useState(false);
const [led, setLed] = useState(false);
const [ledCount, setLedCount] = useState("");
const [note, setNote] = useState("");
const [sendState, setSendState] = useState(null); // { ok, json, bytes, dropped, sent }
const [services, setServices] = useState({ montare: "", livrare: "", proiectare: "", tehnolog: "", transport: "" });
// The standards, from GET /api/defaults when it answers and from the built-in
// fallback otherwise. `dbDown` only raises a banner: every code on this page
// can still be typed by hand, so nothing is blocked.
const [defaults, setDefaults] = useState(STD_FALLBACK);
const [dbDown, setDbDown] = useState(false);
// The standard service prices the inputs fall back to when left empty.
const servicesPrice = defaults.svc;
/* The standards request runs once on mount and can land after the manager has
already pressed «Procesează», so the handler needs the state as it is when
the answer arrives, not as it was on mount. Refs, because nothing renders
from them and re-running the request on every edit would be wrong. */
const parsedRef = useRef(null);
const hwTouchedRef = useRef(false);
const golaTouchedRef = useRef(false);
// The hardware brand as it is when a late standards answer arrives.
const brandRef = useRef("");
// Rebuild the Gola suggestion for a set of standards. Kept here because both
// «Procesează» and a late standards answer need exactly the same thing.
const golaFor = (p, d) => {
const area = IC.matAmount(IC.matchMaterials(p.materials).found, "gola");
return {
mp: area ? IC.golaMetres(area, d.gola_h_m) : "",
...d.mat.gola, unit: "", price: undefined, color: IC.colorFor(d.colors, "gola"),
};
};
// Every edit the manager makes to these two blocks claims them, so a late
// standards answer can no longer overwrite their work.
const editHw = (rows) => { hwTouchedRef.current = true; setHw(rows); };
const editGola = (next) => { golaTouchedRef.current = true; setGola(next); };
// The brand decides which hinge, plate and guide standards are prefilled, so
// untouched rows are rebuilt for it; rows the manager edited stay theirs.
const changeBrand = (b) => {
brandRef.current = b;
setFeronerie(b);
const p = parsedRef.current;
if (p && !hwTouchedRef.current) setHw(IC.buildHardwareRows(p, defaults, b));
};
useEffect(() => {
let alive = true;
window.Api.defaults().then(
(loaded) => {
if (!alive) return;
const d = IC.mergeDefaults(loaded);
setDefaults(d);
setDbDown(false);
// Only overwrite a back panel the manager has not chosen themselves.
setSpate((prev) => (!prev.code || prev.code === STD_FALLBACK.mat.spate.code)
? { ...d.mat.spate, unit: "", price: undefined }
: prev);
// The project was parsed against the fallback standards, so redo the two
// blocks that were prefilled from them — unless the manager got there
// first, in which case their numbers stand.
const p = parsedRef.current;
if (!p) return;
if (!hwTouchedRef.current) setHw(IC.buildHardwareRows(p, d, brandRef.current));
if (!golaTouchedRef.current) setGola(golaFor(p, d));
},
() => { if (alive) setDbDown(true); }
);
return () => { alive = false; };
}, []);
// Wire up language switcher in static header
useEffect(() => {
const sw = document.getElementById("lang-switch");
if (!sw) return;
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;
}, [t]);
const matched = useMemo(
() => IC.matchMaterials(parsed ? parsed.materials : []),
[parsed]
);
const golaArea = IC.r2(IC.matAmount(matched.found, "gola"));
// PRO100 totals the model lists share out.
const corpTotal = IC.r2(IC.matAmount(matched.found, "corp"));
const fatadeTotal = IC.r2(IC.matAmount(matched.found, "fatade"));
const blatTotal = IC.r2(IC.matAmount(matched.found, "blat"));
const onParse = () => {
if (!raw.trim()) { setError(t.invalid); return; }
const p = IC.parsePRO100(raw);
if (p.pieces.length === 0 && p.elements.length === 0 && p.materials.length === 0) {
setError(t.invalid); return;
}
setError("");
setParsed(p);
parsedRef.current = p;
hwTouchedRef.current = false;
golaTouchedRef.current = false;
setHw(IC.buildHardwareRows(p, defaults, feronerie));
setPlacuteEdited(false);
setGola(golaFor(p, defaults));
// New totals: whatever was typed per model belonged to the old project.
setCorpuri(resetQty);
setFatade(resetQty);
setBlat((b) => ({ ...b, blat: resetQty(b.blat) }));
};
const onClear = () => {
setRaw(""); setParsed(null); setError("");
parsedRef.current = null;
hwTouchedRef.current = false;
golaTouchedRef.current = false;
setHw([]); setPlacuteEdited(false);
setGola({ mp: "", ...defaults.mat.gola, unit: "", price: undefined, color: IC.colorFor(defaults.colors, "gola") });
setContrablatLen(""); setContrablatTouched(false);
// Extra models go with the project; the first model's choice stays, as before.
setCorpuri((rows) => resetQty(rows.slice(0, 1)));
setFatade((rows) => resetQty(rows.slice(0, 1)));
setBlat((b) => ({ ...b, blat: resetQty(b.blat.slice(0, 1)) }));
setNote("");
};
const onCalc = () => {
const payload = IC.buildImportPayload({
parsed, corpuri, fatade, blat, contrablatLen, contrablatTouched,
spateCode: spate.code, spateName: spate.name,
gola, golaHM: defaults.gola_h_m,
feronerie, hw, led, ledCount, services, servicesPrice, note,
});
setSendState(sendToBot(payload));
};
return (
{dbDown && (