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

{error &&
{error}
} setUsuario(e.target.value)} autoCapitalize="none" autoCorrect="off" style={inputStyle} /> setClave(e.target.value)} onKeyDown={(e) => e.key === "Enter" && entrar()} style={inputStyle} />
); } /* ================= 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" && ( )}

Dónde sale más barato

{Object.entries(CURRENCIES).map(([code, c]) => ( ))}
{usuario.nombre || usuario.usuario} {usuario.rol === "admin" && ( )}
{view === "list" && ( <>
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}
)} {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" && ( )} {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 (
Foto ampliada 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.

); } function Thumb({ photoId, size, onView, label }) { const url = photoId ? "foto.php?id=" + photoId : null; return ( ); } 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 ( ); })}
); } function HistoryList({ entries, onOpen, onViewPhoto }) { if (!entries.length) { return (

No hay registros todavía.

); } return (
{entries.map((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 ( ); } 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 ? (
setNombre(e.target.value)} style={inputStyle} />
) : (

{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 && ( )}
)} {uniendo && (

Los {product.entries.length} {product.entries.length === 1 ? "precio" : "precios"} de este producto pasan al que elijas, y este deja de existir.

{elegido && (

Todo queda bajo “{elegido.name}”. No se puede deshacer.

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

{photo && (
)} {iaError &&

{iaError}

} {iaCorrio && !iaError && (

Encontré {filas.length} {filas.length === 1 ? "producto" : "productos"}. Revisá los precios antes de guardar: la IA se equivoca leyendo carteles.

)} setStore(e.target.value)} placeholder="Ej: Superseis Villa Morra" style={inputStyle} /> {tiendas.map((t) => {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} /> ))}
); } 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 (
{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 }} />
{sugerido && (
¿Es el mismo que “{sugerido.name}”?
)} {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"}
))}

Agregar a alguien

{aviso &&
{aviso}
} setUsuario(e.target.value)} autoCapitalize="none" style={inputStyle} /> setNombre(e.target.value)} style={inputStyle} /> setClave(e.target.value)} style={inputStyle} />
); } ReactDOM.createRoot(document.getElementById("raiz")).render();