const { useState, useEffect, useRef, useMemo } = React;
/* ================= constantes ================= */
const CURRENCIES = {
PYG: { label: "Guaraní", symbol: "Gs.", decimals: 0, locale: "es-PY", space: true },
USD: { label: "Dólar", symbol: "US$", decimals: 2, locale: "en-US", space: false },
ARS: { label: "Peso", symbol: "$", decimals: 2, locale: "es-AR", space: false },
BRL: { label: "Real", symbol: "R$", decimals: 2, locale: "pt-BR", space: true },
};
const DEFAULT_CURRENCY = "PYG";
const STALE_DAYS = 45;
const UNITS = {
ml:{base:"ml",factor:1}, cc:{base:"ml",factor:1}, l:{base:"ml",factor:1000}, lt:{base:"ml",factor:1000},
lts:{base:"ml",factor:1000}, litro:{base:"ml",factor:1000}, litros:{base:"ml",factor:1000},
g:{base:"g",factor:1}, gr:{base:"g",factor:1}, grs:{base:"g",factor:1}, gramos:{base:"g",factor:1},
kg:{base:"g",factor:1000}, kilo:{base:"g",factor:1000}, kilos:{base:"g",factor:1000}, k:{base:"g",factor:1000},
un:{base:"un",factor:1}, u:{base:"un",factor:1}, unid:{base:"un",factor:1}, unidad:{base:"un",factor:1}, unidades:{base:"un",factor:1},
};
const BASE_LABEL = { ml:{per:1000,name:"litro"}, g:{per:1000,name:"kilo"}, un:{per:1,name:"unidad"} };
const UNIT_OPTIONS = ["", "ml", "l", "g", "kg", "un"];
/* ================= helpers ================= */
function stripAccents(s) { return String(s || "").normalize("NFD").replace(/[\u0300-\u036f]/g, ""); }
function slugify(name) {
let s = stripAccents(String(name || "").toLowerCase());
s = s.replace(/[^a-z0-9.,]+/g, " ").replace(/(\d)[.,](\d)/g, "$1.$2").replace(/[.,]/g, " ").replace(/\s+/g, " ").trim();
s = s.replace(/(\d+(?:\.\d+)?)\s*([a-z]+)/g, (m, num, unit) => {
const u = UNITS[unit];
if (!u) return num + " " + unit;
const c = u.base === "ml" ? (u.factor === 1000 ? "l" : "ml") : u.base === "g" ? (u.factor === 1000 ? "kg" : "g") : "un";
return num + c;
});
const relleno = new Set(["de", "del", "la", "el", "los", "las", "con", "para", "en", "y", "al"]);
return s.split(" ").filter((w) => w && !relleno.has(w)).join(" ").trim();
}
function findProductKey(products, name) {
const target = slugify(name);
if (!target) return null;
for (const [key, p] of Object.entries(products)) if (slugify(p.name) === target) return key;
return null;
}
// busca un producto ya cargado que se parezca al nombre que se esta tipeando.
// no decide nada: solo ofrece, y el usuario confirma.
function parecidoA(products, nombre) {
const base = slugify(nombre);
if (base.length < 3) return null;
const tokensA = base.split(" ").filter(Boolean);
if (!tokensA.length) return null;
const setA = new Set(tokensA);
let mejor = null, mejorPuntaje = 0;
for (const p of Object.values(products)) {
const sb = slugify(p.name);
if (sb === base) return null;
const setB = new Set(sb.split(" ").filter(Boolean));
let comunes = 0;
setA.forEach((t) => { if (setB.has(t)) comunes++; });
const union = new Set([...setA, ...setB]).size;
let puntaje = union ? comunes / union : 0;
if (sb.startsWith(base) || base.startsWith(sb)) puntaje = Math.max(puntaje, 0.7);
if (puntaje > mejorPuntaje) { mejorPuntaje = puntaje; mejor = p; }
}
return mejorPuntaje >= 0.5 ? mejor : null;
}
function parsePrice(raw, currency) {
if (raw === null || raw === undefined) return null;
if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
let s = String(raw).replace(/[^\d.,]/g, "").trim();
if (!s) return null;
const zero = CURRENCIES[currency] && CURRENCIES[currency].decimals === 0;
const pos = Math.max(s.lastIndexOf("."), s.lastIndexOf(","));
let intPart = s, decPart = "";
if (pos !== -1) {
const sep = s[pos], after = s.slice(pos + 1), veces = s.split(sep).length - 1;
if (!zero && veces === 1 && after.length > 0 && after.length <= 2) { intPart = s.slice(0, pos); decPart = after; }
}
const n = Number(intPart.replace(/[.,]/g, "") + (decPart ? "." + decPart : ""));
return Number.isFinite(n) ? n : null;
}
function formatMoney(n, currency) {
const num = Number(n);
if (n === null || n === undefined || Number.isNaN(num)) return "—";
const c = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY];
return c.symbol + (c.space ? " " : "") + num.toLocaleString(c.locale, { minimumFractionDigits: 0, maximumFractionDigits: c.decimals });
}
function aFecha(v) { return new Date(String(v).replace(" ", "T")); }
function formatDate(v) { return aFecha(v).toLocaleDateString("es-PY", { day: "2-digit", month: "short", year: "numeric" }); }
function daysSince(v) { return Math.floor((Date.now() - aFecha(v).getTime()) / 86400000); }
function relativeDate(v) {
const d = daysSince(v);
if (d <= 0) return "hoy";
if (d === 1) return "ayer";
if (d < 30) return "hace " + d + " días";
const m = Math.floor(d / 30);
return m === 1 ? "hace un mes" : "hace " + m + " meses";
}
function toBase(qty, unit) {
const q = typeof qty === "number" ? qty : parsePrice(qty, "USD");
const u = UNITS[stripAccents(String(unit || "").toLowerCase().trim())];
if (!q || q <= 0 || !u) return null;
return { baseQty: q * u.factor, baseUnit: u.base };
}
function unitPriceOf(e) {
if (!e || !e.baseQty || !e.baseUnit || !Number.isFinite(e.price)) return null;
const l = BASE_LABEL[e.baseUnit];
if (!l) return null;
return { value: (e.price / e.baseQty) * l.per, name: l.name, base: e.baseUnit };
}
function formatUnitPrice(e) {
const up = unitPriceOf(e);
return up ? formatMoney(up.value, e.currency) + " por " + up.name : null;
}
function comparableValue(e, base) {
if (!base) return e.price;
const up = unitPriceOf(e);
return up && up.base === base ? up.value : null;
}
function dominantBase(entries) {
const bases = new Set(entries.map((e) => e.baseUnit).filter(Boolean));
if (bases.size !== 1) return null;
return entries.every((e) => e.baseQty > 0) ? [...bases][0] : null;
}
function sortEntries(entries) {
const base = dominantBase(entries);
return [...entries].sort((a, b) => (comparableValue(a, base) ?? a.price) - (comparableValue(b, base) ?? b.price));
}
function groupByCurrency(entries) {
const m = {};
for (const e of entries) (m[e.currency || DEFAULT_CURRENCY] = m[e.currency || DEFAULT_CURRENCY] || []).push(e);
return m;
}
function bestEntry(entries, preferida) {
if (!entries.length) return null;
const g = groupByCurrency(entries);
const grupo = g[preferida] && g[preferida].length ? g[preferida] : Object.values(g).sort((a, b) => b.length - a.length)[0];
return sortEntries(grupo)[0];
}
function fileToImages(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
const render = (maxDim, quality) => {
let w = img.width, h = img.height;
if (w > h && w > maxDim) { h = Math.round((h * maxDim) / w); w = maxDim; }
else if (h > maxDim) { w = Math.round((w * maxDim) / h); h = maxDim; }
const c = document.createElement("canvas");
c.width = w; c.height = h;
c.getContext("2d").drawImage(img, 0, 0, w, h);
return { dataUrl: c.toDataURL("image/jpeg", quality), width: w, height: h };
};
const hi = render(1400, 0.75), low = render(700, 0.6);
resolve({ hiRes: hi.dataUrl, thumb: low.dataUrl, width: hi.width, height: hi.height });
};
img.onerror = reject;
img.src = reader.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function validBox(b) {
if (!b) return false;
const v = [b.x, b.y, b.w, b.h];
if (v.some((n) => typeof n !== "number" || Number.isNaN(n))) return false;
return !(b.w <= 0.02 || b.h <= 0.02 || b.x < 0 || b.y < 0 || b.x + b.w > 1.02 || b.y + b.h > 1.02);
}
/* ================= API ================= */
let CSRF = "";
async function api(accion, { metodo = "GET", datos = null, archivo = "api.php" } = {}) {
const cabeceras = { "Content-Type": "application/json" };
if (CSRF) cabeceras["X-CSRF-Token"] = CSRF;
const res = await fetch(archivo + "?a=" + accion, {
method: metodo,
headers: cabeceras,
credentials: "same-origin",
body: datos ? JSON.stringify(datos) : undefined,
});
let json = {};
try { json = await res.json(); } catch (e) {}
if (!res.ok) {
const err = new Error(json.error || "No se pudo conectar con el servidor.");
err.status = res.status;
throw err;
}
return json;
}
function mapReg(r) {
return {
id: r.id, price: Number(r.precio), currency: r.moneda, store: r.tienda,
date: r.fecha, photoId: r.foto_id, rawPrice: r.precio_crudo,
qty: r.cantidad, unit: r.unidad, baseQty: r.cantidad_base, baseUnit: r.unidad_base,
por: r.cargado_por,
};
}
/* ================= iconos ================= */
const PATHS = {
plus: ["M12 5v14", "M5 12h14"],
check: ["M20 6 9 17l-5-5"],
trash: ["M3 6h18", "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6", "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"],
store: ["M3 9h18v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z", "M3 9l2-5h14l2 5"],
tag: ["M20.6 13.4 12 4.8H4.8V12l8.6 8.6a2 2 0 0 0 2.8 0l4.4-4.4a2 2 0 0 0 0-2.8z", "M8 8h.01"],
search: ["M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z", "M21 21l-4.3-4.3"],
clock: ["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z", "M12 6v6l4 2"],
package: ["M3 7l9-4 9 4v10l-9 4-9-4z", "M3 7l9 4 9-4", "M12 11v10"],
camera: ["M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z", "M16 13a4 4 0 1 1-8 0 4 4 0 0 1 8 0z"],
sparkles: ["M12 3l2.2 5.8L20 11l-5.8 2.2L12 19l-2.2-5.8L4 11l5.8-2.2z"],
x: ["M18 6 6 18", "M6 6l12 12"],
alert: ["M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z", "M12 9v4", "M12 17h.01"],
users: ["M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8", "M22 21v-2a4 4 0 0 0-3-3.9"],
salir: ["M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4", "M16 17l5-5-5-5", "M21 12H9"],
refresh: ["M3 12a9 9 0 0 1 15-6.7L21 8", "M21 3v5h-5", "M21 12a9 9 0 0 1-15 6.7L3 16", "M3 21v-5h5"],
};
function I({ n, size = 16, color = "currentColor", style }) {
return (
{(PATHS[n] || []).map((d, i) => )}
);
}
/* ================= estilos ================= */
const labelStyle = { fontSize: 12, fontWeight: 600, color: "#6B7A70", display: "block", marginBottom: 6, marginTop: 2 };
const inputStyle = { width: "100%", padding: "10px 12px", borderRadius: 10, border: "1px solid #DCD7C9", background: "white", fontSize: 14, marginBottom: 14, boxSizing: "border-box" };
const inputFlex = { ...inputStyle, width: "auto", minWidth: 0, marginBottom: 0 };
const primaryBtn = { display: "flex", alignItems: "center", justifyContent: "center", gap: 6, background: "#2E6B4F", color: "white", border: "none", borderRadius: 10, padding: 12, fontSize: 14, fontWeight: 600, cursor: "pointer" };
const secondaryBtn = { background: "white", color: "#3E4A44", border: "1px solid #DCD7C9", borderRadius: 10, padding: 12, fontSize: 14, fontWeight: 600, cursor: "pointer" };
const cajaStyle = { background: "white", border: "1px solid #DCD7C9", borderRadius: 14, padding: 10 };
/* ================= login ================= */
function Login({ onEntrar }) {
const [usuario, setUsuario] = useState("");
const [clave, setClave] = useState("");
const [error, setError] = useState("");
const [cargando, setCargando] = useState(false);
async function entrar() {
if (!usuario.trim() || !clave) return;
setCargando(true); setError("");
try {
const r = await api("login", { metodo: "POST", datos: { usuario: usuario.trim(), clave } });
CSRF = r.csrf;
onEntrar(r.usuario);
} catch (e) {
setError(e.message);
setCargando(false);
}
}
return (
Dónde sale más barato
Precios de góndola de Paraguay Courier.
);
}
/* ================= app ================= */
function App() {
const [sesion, setSesion] = useState(null);
useEffect(() => {
api("estado")
.then((r) => { CSRF = r.csrf; setSesion(r.sesion ? r.usuario : false); })
.catch(() => setSesion(false));
}, []);
if (sesion === null) return Cargando…
;
if (!sesion) return ;
return { api("logout", { metodo: "POST" }).catch(() => {}); setSesion(false); }} />;
}
function Comparador({ usuario, onSalir }) {
const [tab, setTab] = useState("products");
const [view, setView] = useState("list");
const [products, setProducts] = useState({});
const [tiendas, setTiendas] = useState([]);
const [activeKey, setActiveKey] = useState(null);
const [activeEntry, setActiveEntry] = useState(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [error, setError] = useState("");
const [currency, setCurrency] = useState(() => localStorage.getItem("moneda") || DEFAULT_CURRENCY);
const [lightbox, setLightbox] = useState(null);
const [confirmar, setConfirmar] = useState(null);
useEffect(() => { cargar(); }, []);
async function cargar() {
setLoading(true);
try {
const r = await api("datos");
const map = {};
for (const p of r.productos) map[String(p.id)] = { id: p.id, name: p.nombre, entries: p.registros.map(mapReg) };
setProducts(map);
setTiendas(r.tiendas || []);
setError("");
} catch (e) {
setError(e.status === 401 ? "Se cerró la sesión. Volvé a entrar." : e.message);
} finally {
setLoading(false);
}
}
function cambiarMoneda(c) { setCurrency(c); localStorage.setItem("moneda", c); }
async function guardar(filas, meta) {
try {
await api("guardar", {
metodo: "POST",
datos: {
tienda: meta.store,
moneda: currency,
foto: meta.photo || null,
items: filas.map((r) => ({
nombre: r.nombre.trim(),
precio: parsePrice(r.precioTexto, currency),
precio_crudo: r.precioTexto,
cantidad: r.cantidad ? parsePrice(r.cantidad, "USD") : null,
unidad: r.unidad || null,
})),
},
});
await cargar();
return true;
} catch (e) {
setError(e.message);
return false;
}
}
async function accionProducto(accion, datos, irA) {
try {
await api(accion, { metodo: "POST", datos });
await cargar();
if (irA === null) { setView("list"); setActiveKey(null); }
else if (irA !== undefined) setActiveKey(String(irA));
} catch (e) { setError(e.message); }
}
async function borrar(id) {
try {
await api("borrar", { metodo: "POST", datos: { id } });
await cargar();
setView("list"); setActiveKey(null); setActiveEntry(null);
} catch (e) { setError(e.message); }
}
const q = stripAccents(search.toLowerCase());
const productKeys = Object.keys(products).filter((k) => stripAccents(products[k].name.toLowerCase()).includes(q));
const historial = Object.entries(products)
.flatMap(([key, p]) => p.entries.map((e) => ({ ...e, productKey: key, productName: p.name })))
.filter((e) => stripAccents(e.productName.toLowerCase()).includes(q) || stripAccents((e.store || "").toLowerCase()).includes(q))
.sort((a, b) => aFecha(b.date) - aFecha(a.date));
function goHome() { setView("list"); setActiveKey(null); setActiveEntry(null); }
return (
{view !== "list" && (
← Volver
)}
Dónde sale más barato
{Object.entries(CURRENCIES).map(([code, c]) => (
cambiarMoneda(code)} title={c.label}
style={{ border: "none", background: currency === code ? "#2E6B4F" : "transparent", color: currency === code ? "white" : "#6B7A70", fontSize: 11, fontWeight: 600, borderRadius: 6, padding: "5px 6px", cursor: "pointer" }}>
{c.symbol}
))}
{usuario.nombre || usuario.usuario}
Actualizar
{usuario.rol === "admin" && (
setView("usuarios")} style={{ border: "none", background: "none", color: "#6B7A70", cursor: "pointer", padding: 0, display: "flex", alignItems: "center", gap: 4, fontSize: 11.5 }}>
Equipo
)}
Salir
{view === "list" && (
<>
setTab("products")} style={tabBtnStyle(tab === "products")}> Productos
setTab("history")} style={tabBtnStyle(tab === "history")}> Historial
setSearch(e.target.value)}
placeholder={tab === "products" ? "Buscar producto…" : "Buscar por producto o tienda…"}
style={{ ...inputStyle, marginBottom: 0, padding: "10px 12px 10px 34px" }} />
>
)}
{error && (
{error}
setError("")} aria-label="Cerrar" style={{ border: "none", background: "none", color: "#8A3B12", cursor: "pointer", padding: 0 }}>
)}
{loading &&
Cargando…
}
{!loading && view === "list" && tab === "products" && (
{ setActiveKey(k); setView("detail"); }} onViewPhoto={setLightbox} />
)}
{!loading && view === "list" && tab === "history" && (
{ setActiveEntry({ key: e.productKey, id: e.id }); setView("entry"); }} onViewPhoto={setLightbox} />
)}
{!loading && view === "detail" && products[activeKey] && (
setConfirmar({ id, label: label + " se borra del historial." })}
onUnir={(origen, dest) => accionProducto("producto_unir", { origen_id: origen, destino_id: dest }, dest)}
onRenombrar={(id, nombre) => accionProducto("producto_renombrar", { id, nombre })}
onQuitarPresentacion={(id) => accionProducto("producto_sin_presentacion", { id })}
onBorrarProducto={(id, nombre, cuantos) => setConfirmar({ tipo: "producto", id, label: "“" + nombre + "” y sus " + cuantos + (cuantos === 1 ? " precio se borran." : " precios se borran.") })} />
)}
{!loading && view === "entry" && activeEntry && products[activeEntry.key] && (
e.id === activeEntry.id)}
usuario={usuario} onViewPhoto={setLightbox}
onDelete={(label) => setConfirmar({ id: activeEntry.id, label })}
onViewProduct={() => { setActiveKey(activeEntry.key); setView("detail"); }} />
)}
{view === "add" && (
{ const ok = await guardar(filas, meta); if (ok) goHome(); return ok; }} />
)}
{view === "usuarios" && usuario.rol === "admin" && }
{view !== "add" && view !== "usuarios" && (
setView("add")} aria-label="Registrar precios"
style={{ position: "fixed", bottom: 24, right: "max(20px, calc(50% - 240px + 20px))", width: 58, height: 58, borderRadius: "50%", background: "#2E6B4F", color: "white", border: "none", boxShadow: "0 6px 16px rgba(46,107,79,0.4)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
)}
{lightbox &&
setLightbox(null)} />}
{confirmar && (
setConfirmar(null)}
onConfirm={() => { if (confirmar.tipo === "producto") accionProducto("producto_borrar", { id: confirmar.id }, null); else borrar(confirmar.id); setConfirmar(null); }} />
)}
);
}
function tabBtnStyle(active) {
return { display: "flex", alignItems: "center", gap: 5, border: "1px solid " + (active ? "#2E6B4F" : "#DCD7C9"), background: active ? "#E3F0E8" : "white", color: active ? "#2E6B4F" : "#6B7A70", fontSize: 12, fontWeight: 600, borderRadius: 8, padding: "6px 10px", cursor: "pointer" };
}
/* ================= piezas ================= */
function Lightbox({ url, onClose }) {
useEffect(() => {
const k = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", k);
return () => window.removeEventListener("keydown", k);
}, [onClose]);
return (
e.stopPropagation()} style={{ maxWidth: "100%", maxHeight: "85vh", borderRadius: 12, objectFit: "contain" }} />
);
}
function ConfirmDialog({ titulo, label, onCancel, onConfirm }) {
return (
{titulo || "Eliminar registro"}
{label} No se puede deshacer.
Cancelar
Eliminar
);
}
function Thumb({ photoId, size, onView, label }) {
const url = photoId ? "foto.php?id=" + photoId : null;
return (
{ e.stopPropagation(); if (url) onView(url); }}
aria-label={label ? "Ver foto de " + label : "Ver foto"}
style={{ width: size, height: size, borderRadius: 10, overflow: "hidden", flexShrink: 0, background: "#EEEBE1", border: "none", padding: 0, cursor: url ? "zoom-in" : "default" }}>
{url ?
: 50 ? 20 : 16} /> }
);
}
function HomeList({ productKeys, products, currency, onOpen, onViewPhoto }) {
if (!productKeys.length) {
return (
Todavía no hay precios. Tocá + y sacá una foto de la góndola.
);
}
return (
{productKeys.map((key) => {
const p = products[key];
const cheapest = bestEntry(p.entries, currency);
if (!cheapest) return null;
const mismos = p.entries.filter((e) => e.currency === cheapest.currency);
const ord = sortEntries(mismos);
const caro = ord[ord.length - 1];
const ahorro = ord.length > 1 ? caro.price - cheapest.price : 0;
const unitTxt = formatUnitPrice(cheapest);
const viejo = daysSince(cheapest.date) > STALE_DAYS;
const mixto = new Set(p.entries.map((e) => e.currency)).size > 1;
return (
onOpen(key)}
style={{ ...cajaStyle, display: "flex", alignItems: "center", gap: 12, cursor: "pointer", textAlign: "left", font: "inherit", color: "inherit", width: "100%" }}>
{p.name}
{cheapest.store || "—"}
{unitTxt &&
{unitTxt}
}
{formatMoney(cheapest.price, cheapest.currency)}
{ahorro > 0 &&
ahorrás {formatMoney(ahorro, cheapest.currency)}
}
{viejo ? relativeDate(cheapest.date) + " · revisar" : relativeDate(cheapest.date)}
{mixto &&
hay otras monedas
}
);
})}
);
}
function HistoryList({ entries, onOpen, onViewPhoto }) {
if (!entries.length) {
return (
No hay registros todavía.
);
}
return (
{entries.map((e) => (
onOpen(e)}
style={{ ...cajaStyle, borderRadius: 12, padding: 9, display: "flex", alignItems: "center", gap: 12, cursor: "pointer", textAlign: "left", font: "inherit", color: "inherit", width: "100%" }}>
{e.productName}
{e.store} · {formatDate(e.date)}{e.por ? " · " + e.por : ""}
{formatMoney(e.price, e.currency)}
{formatUnitPrice(e) &&
{formatUnitPrice(e)}
}
))}
);
}
function EntryCard({ entry, highlight, puedeBorrar, onDelete, onViewPhoto }) {
const unitTxt = formatUnitPrice(entry);
const viejo = daysSince(entry.date) > STALE_DAYS;
return (
{formatMoney(entry.price, entry.currency)}
{highlight && Más barato }
{unitTxt &&
{unitTxt}
}
{entry.store}
{formatDate(entry.date)}{viejo ? " · precio viejo" : ""}{entry.por ? " · cargó " + entry.por : ""}
{puedeBorrar && (
)}
);
}
function puedeBorrarlo(usuario, entry) {
return usuario.rol === "admin" || entry.por === usuario.usuario;
}
function AccionBtn({ children, onClick, activo }) {
return (
{children}
);
}
function ProductDetail({ product, products, usuario, onDelete, onViewPhoto, onUnir, onRenombrar, onQuitarPresentacion, onBorrarProducto }) {
const [editando, setEditando] = useState(false);
const [nombre, setNombre] = useState(product.name);
const [uniendo, setUniendo] = useState(false);
const [destino, setDestino] = useState("");
useEffect(() => {
setNombre(product.name); setEditando(false); setUniendo(false); setDestino("");
}, [product.id]);
const otros = Object.values(products)
.filter((p) => p.id !== product.id)
.sort((a, b) => a.name.localeCompare(b.name, "es"));
const tienePresentacion = product.entries.some((e) => e.baseUnit);
const puedeBorrarProducto = usuario.rol === "admin" || product.entries.every((e) => e.por === usuario.usuario);
const elegido = otros.find((p) => String(p.id) === destino);
const grupos = groupByCurrency(product.entries);
const codigos = Object.keys(grupos).sort((a, b) => grupos[b].length - grupos[a].length);
return (
{editando ? (
) : (
{product.name}
)}
{!editando && (
setEditando(true)}>Renombrar
{otros.length > 0 &&
setUniendo(!uniendo)}>Unir con otro }
{tienePresentacion && (
onQuitarPresentacion(product.id)}>No se mide por litro ni kilo
)}
{puedeBorrarProducto && (
onBorrarProducto(product.id, product.name, product.entries.length)}
style={{ border: "1px solid #E3B394", background: "white", color: "#A8432A", fontSize: 12, fontWeight: 600, borderRadius: 8, padding: "6px 10px", cursor: "pointer", display: "flex", alignItems: "center", gap: 5 }}>
Eliminar producto
)}
)}
{uniendo && (
Los {product.entries.length} {product.entries.length === 1 ? "precio" : "precios"} de este producto pasan al que elijas, y este deja de existir.
setDestino(e.target.value)} style={{ ...inputStyle, marginBottom: 10 }}>
Elegí el producto que se queda…
{otros.map((p) => {p.name} )}
{elegido && (
Todo queda bajo “{elegido.name}”. No se puede deshacer.
)}
{ setUniendo(false); setDestino(""); }} style={{ ...secondaryBtn, flex: 1, padding: "9px", fontSize: 13 }}>Cancelar
onUnir(product.id, Number(destino))} disabled={!destino}
style={{ ...primaryBtn, flex: 1, padding: "9px", fontSize: 13, opacity: destino ? 1 : 0.5 }}>
Unir
)}
{codigos.map((code) => {
const ord = sortEntries(grupos[code]);
const base = dominantBase(grupos[code]);
return (
{codigos.length > 1 &&
En {CURRENCIES[code] ? CURRENCIES[code].label : code}
}
{base && ord.length > 1 &&
Ordenado por precio por {BASE_LABEL[base].name}, no por precio de góndola.
}
{ord.map((entry, i) => (
onDelete(entry.id, formatMoney(entry.price, entry.currency) + " en " + entry.store)} onViewPhoto={onViewPhoto} />
))}
);
})}
);
}
function EntryDetail({ product, entry, usuario, onDelete, onViewProduct, onViewPhoto }) {
if (!entry) return null;
const mismos = product.entries.filter((e) => e.currency === entry.currency);
const ord = sortEntries(mismos);
return (
{product.name}
onDelete(formatMoney(entry.price, entry.currency) + " en " + entry.store)} onViewPhoto={onViewPhoto} />
{product.entries.length > 1 && (
Ver los {product.entries.length} precios de este producto
)}
);
}
/* ================= alta ================= */
let filaSeq = 0;
function nuevaFila(datos) {
return { id: "f" + Date.now() + "-" + filaSeq++, nombre: "", precioTexto: "", cantidad: "", unidad: "", coincideCon: null, box: null, confianza: null, incluir: true, ...(datos || {}) };
}
const ERRORES_IA = {
429: "Alcanzaste el límite de análisis. Esperá un rato o cargalos a mano.",
422: "No encontré productos en la foto. Cargalos a mano o sacá otra más cerca.",
503: "Falta configurar la API key en el servidor.",
};
function AddEntry({ products, tiendas, currency, onCancel, onSave }) {
const [filas, setFilas] = useState([nuevaFila()]);
const [store, setStore] = useState("");
const [photo, setPhoto] = useState(null);
const hiRes = useRef(null);
const [guardando, setGuardando] = useState(false);
const [iaCargando, setIaCargando] = useState(false);
const [iaError, setIaError] = useState("");
const [iaCorrio, setIaCorrio] = useState(false);
const fileRef = useRef(null);
const nombresExistentes = Object.values(products).map((p) => p.name);
async function elegirFoto(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
setIaError(""); setIaCorrio(false);
try {
const r = await fileToImages(file);
hiRes.current = r.hiRes;
setPhoto(r.thumb);
} catch (err) {
hiRes.current = null; setPhoto(null);
setIaError("No se pudo leer la imagen. Probá con otra foto.");
}
}
async function correrIA() {
if (!hiRes.current) return;
setIaCargando(true); setIaError("");
try {
const r = await api("", { metodo: "POST", archivo: "analizar.php", datos: { imagen: hiRes.current, moneda: currency, existentes: nombresExistentes } });
const detectadas = (r.items || []).map((it) =>
nuevaFila({
nombre: it.coincide_con || it.nombre || "",
precioTexto: it.precio !== null && it.precio !== undefined ? String(it.precio) : "",
cantidad: it.cantidad !== null && it.cantidad !== undefined ? String(it.cantidad) : "",
unidad: UNITS[String(it.unidad || "").toLowerCase()] ? String(it.unidad).toLowerCase() : "",
coincideCon: it.coincide_con || null,
box: validBox(it.box) ? it.box : null,
confianza: it.confianza_precio || null,
})
);
setFilas(detectadas.length ? detectadas : [nuevaFila()]);
setIaCorrio(true);
} catch (e) {
setIaError(ERRORES_IA[e.status] || e.message);
} finally {
setIaCargando(false);
}
}
const validas = filas.filter((f) => f.incluir && f.nombre.trim() && parsePrice(f.precioTexto, currency));
const puedeGuardar = validas.length > 0 && store.trim() && !guardando;
const conCaja = filas.filter((f) => f.box);
return (
Nuevos precios
Foto de la góndola
fileRef.current && fileRef.current.click()}
style={{ width: "100%", height: photo ? 200 : 120, borderRadius: 12, border: "1.5px dashed #B7AF9C", background: photo ? "#111" : "#EFEBDE", overflow: "hidden", marginBottom: 8, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", color: "#6B7A70", gap: 6, position: "relative", padding: 0 }}>
{photo ? (
<>
{conCaja.map((f) => (
{filas.indexOf(f) + 1}
))}
>
) : (
<>Tocá para sacar la foto >
)}
{photo && (
fileRef.current && fileRef.current.click()} style={{ ...secondaryBtn, flex: 1, padding: "8px 10px", fontSize: 13 }}>Cambiar foto
{iaCargando ? "Leyendo precios…" : iaCorrio ? "Leer de nuevo" : "Leer productos y precios"}
)}
{iaError &&
{iaError}
}
{iaCorrio && !iaError && (
Encontré {filas.length} {filas.length === 1 ? "producto" : "productos"}. Revisá los precios antes de guardar: la IA se equivoca leyendo carteles.
)}
Tienda
setStore(e.target.value)} placeholder="Ej: Superseis Villa Morra" style={inputStyle} />
{tiendas.map((t) => )}
Productos y precios
{nombresExistentes.map((n) => )}
{filas.map((fila, i) => (
1} products={products} currency={currency}
onChange={(patch) => setFilas((fs) => fs.map((f) => (f.id === fila.id ? { ...f, ...patch } : f)))}
onRemove={() => setFilas((fs) => (fs.length === 1 ? [nuevaFila()] : fs.filter((f) => f.id !== fila.id)))}
puedeQuitar={filas.length > 1} />
))}
setFilas((fs) => [...fs, nuevaFila()])} style={{ ...secondaryBtn, width: "100%", marginBottom: 16, fontSize: 13, padding: 9, display: "flex", alignItems: "center", justifyContent: "center", gap: 5 }}>
Agregar otro producto
Cancelar
{ setGuardando(true); await onSave(validas, { store, photo }); setGuardando(false); }}
style={{ ...primaryBtn, flex: 1.4, opacity: puedeGuardar ? 1 : 0.5, cursor: puedeGuardar ? "pointer" : "default" }}>
{guardando ? "Guardando…" : validas.length > 1 ? "Guardar " + validas.length + " precios" : "Guardar precio"}
);
}
function FilaProducto({ fila, numero, mostrarNumero, products, currency, onChange, onRemove, puedeQuitar }) {
const precio = parsePrice(fila.precioTexto, currency);
const key = findProductKey(products, fila.nombre);
const producto = key ? products[key] : null;
const sugerido = !producto ? parecidoA(products, fila.nombre) : null;
const base = toBase(parsePrice(fila.cantidad, "USD"), fila.unidad);
const borrador = precio ? { price: precio, currency, baseQty: base ? base.baseQty : null, baseUnit: base ? base.baseUnit : null } : null;
const unitTxt = borrador ? formatUnitPrice(borrador) : null;
return (
onChange({ incluir: !fila.incluir })} aria-label={fila.incluir ? "No registrar" : "Registrar"}
style={{ width: 20, height: 20, borderRadius: 6, border: fila.incluir ? "none" : "1.5px solid #B7AF9C", background: fila.incluir ? "#2E6B4F" : "white", color: "white", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flexShrink: 0, padding: 0 }}>
{fila.incluir && }
{mostrarNumero && {numero} }
onChange({ nombre: e.target.value })} placeholder="Ej: Aceite de girasol 900ml"
style={{ ...inputFlex, flex: 1, padding: "8px 10px", fontSize: 13.5 }} />
{puedeQuitar && }
{CURRENCIES[currency].symbol}
onChange({ precioTexto: e.target.value })} placeholder="0"
style={{ flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent", padding: "8px 10px 8px 5px", fontSize: 13.5, boxSizing: "border-box" }} />
onChange({ cantidad: e.target.value })} placeholder="900"
style={{ ...inputFlex, flex: 0.7, padding: "8px 10px", fontSize: 13.5 }} />
onChange({ unidad: e.target.value })}
style={{ ...inputFlex, flex: 0.6, padding: "8px 6px", fontSize: 13 }}>
{UNIT_OPTIONS.map((u) => {u || "—"} )}
{sugerido && (
¿Es el mismo que “{sugerido.name}”?
onChange({ nombre: sugerido.name })}
style={{ border: "1px solid #3E5C99", background: "white", color: "#3E5C99", fontSize: 11.5, fontWeight: 600, borderRadius: 7, padding: "5px 9px", cursor: "pointer", whiteSpace: "nowrap" }}>
Usar ese nombre
)}
{fila.precioTexto && !precio &&
No entiendo ese precio. Escribilo solo con números.
}
{unitTxt &&
{unitTxt}
}
{fila.confianza === "baja" && (
La IA no leyó bien este cartel. Verificá el precio.
)}
{fila.incluir && producto &&
}
);
}
function Comparacion({ product, borrador, currency }) {
const entries = product.entries.filter((e) => e.currency === currency);
if (!entries.length) {
return Ya está guardado, pero con registros en otra moneda.
;
}
const base = dominantBase([...entries, ...(borrador ? [borrador] : [])]);
const ord = sortEntries(entries);
const barato = ord[0], caro = ord[ord.length - 1];
const pros = [], contras = [];
if (borrador) {
const mio = comparableValue(borrador, base) ?? borrador.price;
const suyo = comparableValue(barato, base) ?? barato.price;
const porUnidad = base && base !== "un" ? " por " + BASE_LABEL[base].name : "";
if (mio < suyo) {
const dif = (comparableValue(caro, base) ?? caro.price) - mio;
pros.push("Es el mejor precio" + porUnidad + " que tenés registrado" + (entries.length > 1 && dif > 0 ? ": " + formatMoney(dif, currency) + " menos que en " + caro.store : "") + ".");
} else if (mio === suyo) {
pros.push("Es igual al mejor precio que tenés, en " + barato.store + ".");
} else {
contras.push("En " + barato.store + " está más barato" + porUnidad + ": " + formatMoney(barato.price, barato.currency) + " (" + relativeDate(barato.date) + "). Diferencia de " + formatMoney(mio - suyo, currency) + porUnidad + ".");
}
const prom = entries.reduce((s, e) => s + (comparableValue(e, base) ?? e.price), 0) / entries.length;
if (mio < prom) pros.push("Por debajo del promedio que vienen pagando (" + formatMoney(prom, currency) + porUnidad + ").");
else if (mio > prom) contras.push("Por encima del promedio que vienen pagando (" + formatMoney(prom, currency) + porUnidad + ").");
if (daysSince(barato.date) > STALE_DAYS) contras.push("Ojo: el precio de " + barato.store + " es de " + relativeDate(barato.date) + " y puede haber cambiado.");
} else {
pros.push("Ya está registrado. El más barato es " + formatMoney(barato.price, barato.currency) + " en " + barato.store + ".");
}
return (
{pros.map((p, i) => (
{p}
))}
{contras.map((c, i) => (
{c}
))}
);
}
/* ================= equipo ================= */
function PanelUsuarios({ onError }) {
const [lista, setLista] = useState([]);
const [usuario, setUsuario] = useState("");
const [nombre, setNombre] = useState("");
const [clave, setClave] = useState("");
const [rol, setRol] = useState("usuario");
const [aviso, setAviso] = useState("");
useEffect(() => { cargar(); }, []);
async function cargar() {
try { const r = await api("usuarios"); setLista(r.usuarios || []); } catch (e) { onError(e.message); }
}
async function crear() {
setAviso("");
try {
await api("usuario_crear", { metodo: "POST", datos: { usuario: usuario.trim(), nombre: nombre.trim(), clave, rol } });
setUsuario(""); setNombre(""); setClave(""); setRol("usuario");
setAviso("Usuario creado. Pasale la contraseña por un canal seguro y que la cambie después.");
cargar();
} catch (e) { onError(e.message); }
}
async function cambiarEstado(u) {
try { await api("usuario_estado", { metodo: "POST", datos: { id: u.id, activo: Number(u.activo) === 1 ? 0 : 1 } }); cargar(); }
catch (e) { onError(e.message); }
}
return (
Equipo
{lista.map((u) => (
{u.nombre || u.usuario}
{u.usuario} · {u.rol}{Number(u.activo) === 1 ? "" : " · desactivado"}
cambiarEstado(u)} style={{ ...secondaryBtn, padding: "6px 10px", fontSize: 12 }}>
{Number(u.activo) === 1 ? "Desactivar" : "Activar"}
))}
Agregar a alguien
{aviso &&
{aviso}
}
Usuario
setUsuario(e.target.value)} autoCapitalize="none" style={inputStyle} />
Nombre
setNombre(e.target.value)} style={inputStyle} />
Contraseña provisoria (mínimo 10 caracteres)
setClave(e.target.value)} style={inputStyle} />
Rol
setRol(e.target.value)} style={inputStyle}>
Usuario — carga precios y borra lo suyo
Admin — además maneja el equipo
Crear usuario
);
}
ReactDOM.createRoot(document.getElementById("raiz")).render( );