// admin.jsx — Panel de gestión de Mi PC y su Mundo (mecatronilab.com/#/admin) // ───────────────────────────────────────────────────────────────────────────── // Todo lo que se hace aquí queda en el servidor, no en el navegador: // · Usuarios con contraseña cifrada en la base MySQL de Hostinger (api/panel.php). // Roles: Administrador (todo) y Supervisión (contenido web, fotos, actualizar catálogo). // · Contenido web por producto (marca, descripción, ficha técnica, ocultar, destacar) // → assets/contenido.json, que lee la tienda. // · Fotos → api/fotos.php. · "Actualizar ahora" → api/catalogo.php. // · Bitácora: quién cambió qué (pestaña Actividad, solo administrador). // Nombre, precio, categoría y unidades NO se editan aquí: vienen de la hoja INVENTARIO // de la Plataforma y la tienda los toma sola cada 10 minutos. // ───────────────────────────────────────────────────────────────────────────── const INV = () => window.MiPCInventario; const fmtCOP = (n) => window.MiPCInventario.formatoCOP(n || 0); const PANEL_API = 'api/panel.php'; const FOTOS_API = 'api/fotos.php'; const CATALOGO_API = 'api/catalogo.php'; // Sesión del panel (la confirma el servidor; aquí solo se usa para mostrar u ocultar botones) const PANEL = { sesion: null, roles: {}, puede(perm) { const s = this.sesion; return !!s && (s.permisos.includes('*') || s.permisos.includes(perm)); }, }; // Hook: re-render cuando el inventario cambia en cualquier parte function useInventario() { const [, force] = React.useState(0); React.useEffect(() => { const h = () => force(x => x + 1); window.addEventListener('inventario-actualizado', h); return () => window.removeEventListener('inventario-actualizado', h); }, []); return INV().productos; } // ── Llamadas al servidor ────────────────────────────────────────────── // Si la sesión venció (401), el panel vuelve a la pantalla de ingreso. async function respuesta(r) { let j = null; try { j = await r.json(); } catch (e) {} if (!r.ok || !j || !j.ok) { if (r.status === 401) window.dispatchEvent(new CustomEvent('panel-sesion-terminada')); const err = new Error((j && j.error) || ('El servidor respondió ' + r.status + '.')); err.status = r.status; throw err; } return j; } async function apiPost(url, accion, campos) { const fd = new FormData(); fd.append('accion', accion); Object.entries(campos || {}).forEach(([k, v]) => fd.append(k, v)); let r; try { r = await fetch(url, { method: 'POST', body: fd, cache: 'no-store', credentials: 'same-origin' }); } catch (e) { throw new Error('Sin conexión con el servidor.'); } return respuesta(r); } async function apiGet(url, accion, params) { const q = new URLSearchParams({ accion, ...(params || {}) }); let r; try { r = await fetch(url + '?' + q.toString(), { cache: 'no-store', credentials: 'same-origin' }); } catch (e) { throw new Error('Sin conexión con el servidor.'); } return respuesta(r); } const apiFotos = (accion, campos) => apiPost(FOTOS_API, accion, campos); const fotosDe = (p) => (p && p.sku && window.MiPCFotos) ? window.MiPCFotos.de(p.sku) : []; const fechaCorta = (s) => { if (!s) return '—'; const d = new Date(String(s).replace(' ', 'T')); return isNaN(d) ? s : d.toLocaleString('es-CO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); }; // ── Ingreso ─────────────────────────────────────────────────────────── function AdminLogin({ onDone, onNavigate }) { const [u, setU] = React.useState(''); const [p, setP] = React.useState(''); const [err, setErr] = React.useState(''); const [ocupado, setOcupado] = React.useState(false); const submit = async (e) => { e.preventDefault(); setOcupado(true); setErr(''); try { await apiPost(PANEL_API, 'entrar', { usuario: u.trim(), pass: p }); setP(''); onDone(); } catch (x) { setErr(x.message); } setOcupado(false); }; return (
Mi PC y su Mundo

Panel de gestión

Ingresa con tu usuario para administrar la tienda.

setU(e.target.value)} autoCapitalize="none" autoComplete="username" /> setP(e.target.value)} autoComplete="current-password" /> {err &&
{err}
}
); } // ── Primer administrador (solo existe mientras no haya ningún usuario) ── function CrearAdmin({ onDone, onNavigate }) { const [f, setF] = React.useState({ usuario: '', nombre: '', pass: '', pass2: '', clave: '' }); const [err, setErr] = React.useState(''); const [ocupado, setOcupado] = React.useState(false); const set = (k, v) => setF(s => ({ ...s, [k]: v })); const submit = async (e) => { e.preventDefault(); setErr(''); if (f.pass.length < 8) { setErr('La contraseña debe tener al menos 8 caracteres.'); return; } if (f.pass !== f.pass2) { setErr('Las dos contraseñas no coinciden.'); return; } setOcupado(true); try { await apiPost(PANEL_API, 'crear_admin', { usuario: f.usuario.trim().toLowerCase(), nombre: f.nombre.trim(), pass: f.pass, clave: f.clave.trim() }); onDone(); } catch (x) { setErr(x.message); } setOcupado(false); }; return (
Mi PC y su Mundo

Crea el administrador

Es la primera vez: crea tu usuario. Después, desde "Usuarios", creas los demás.

set('usuario', e.target.value)} autoCapitalize="none" autoComplete="username" placeholder="ej: marco" /> set('nombre', e.target.value)} autoComplete="name" /> set('pass', e.target.value)} autoComplete="new-password" /> set('pass2', e.target.value)} autoComplete="new-password" /> set('clave', e.target.value)} autoComplete="off" /> {err &&
{err}
}
La clave de publicación confirma que eres tú. Las contraseñas quedan cifradas en el servidor.
); } // ── Stat tile ───────────────────────────────────────────────────────── function Stat({ label, value, sub, tone }) { return (
{value}
{label}
{sub != null &&
{sub}
}
); } // ── Dashboard ───────────────────────────────────────────────────────── const haceCuanto = (ts) => { if (!ts) return 'sin dato'; const m = Math.max(0, Math.round((Date.now() / 1000 - ts) / 60)); return m < 1 ? 'hace menos de 1 min' : m < 60 ? 'hace ' + m + ' min' : 'hace ' + Math.round(m / 60) + ' h'; }; // Regenera el catálogo desde la hoja ya mismo (si no, se regenera solo cada 10 min) function ActualizarCatalogo() { useInventario(); const [ocupado, setOcupado] = React.useState(false); const [msg, setMsg] = React.useState({ t: '', tono: '' }); const actualizar = async () => { setOcupado(true); setMsg({ t: 'Leyendo la hoja de inventario…', tono: '' }); try { const j = await apiPost(CATALOGO_API, 'refrescar', {}); await window.MiPCCatalogo.cargar(true); setMsg({ t: j.ocupado ? 'Ya se estaba actualizando; en un momento queda.' : 'Listo: ' + j.n + ' productos, tal como están en la hoja.', tono: 'ok' }); } catch (e) { setMsg({ t: e.message, tono: 'bad' }); } setOcupado(false); }; return (
Catálogo de la tienda Se toma de la hoja INVENTARIO de la Plataforma y se actualiza solo cada 10 minutos. Última vez: {haceCuanto(INV().generado)}.
{PANEL.puede('catalogo.actualizar') && } {msg.t &&
{msg.t}
}
); } function AdminDashboard({ go }) { useInventario(); const r = INV().resumen(); const cats = INV().categorias; const conFoto = INV().productos.filter(p => fotosDe(p).length > 0).length; return (

Resumen

{r.ocultos.length > 0 && }
{conFoto < r.total && ( )}

Productos por categoría

{cats.map(c => { const n = r.porCat[c.id] || 0; const pct = r.total ? Math.round(n / r.total * 100) : 0; return ( ); })}
{r.sinPrecio.length > 0 && ( <>

Sin precio en la hoja

{r.sinPrecio.slice(0, 12).map(p => ( {p.sku} · {p.nombre.slice(0, 30)}{p.nombre.length > 30 ? '…' : ''} ))} {r.sinPrecio.length > 12 && +{r.sinPrecio.length - 12} más}
)}
); } // ── Fotos del producto (se publican de verdad en la tienda vía api/fotos.php) ── // Deja la foto cuadrada 900×900 con fondo blanco (como las fichas que ya están) y en JPEG. function prepararFoto(file) { return new Promise((res, rej) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { const S = 900, c = document.createElement('canvas'); c.width = c.height = S; const x = c.getContext('2d'); x.fillStyle = '#fff'; x.fillRect(0, 0, S, S); const k = Math.min(S / img.naturalWidth, S / img.naturalHeight); const w = img.naturalWidth * k, h = img.naturalHeight * k; x.drawImage(img, (S - w) / 2, (S - h) / 2, w, h); URL.revokeObjectURL(url); res(c.toDataURL('image/jpeg', 0.86)); }; img.onerror = () => { URL.revokeObjectURL(url); rej(new Error('No se pudo leer esa imagen. Prueba con JPG o PNG.')); }; img.src = url; }); } function FotosProducto({ p }) { const [fotos, setFotos] = React.useState(() => fotosDe(p)); const [nueva, setNueva] = React.useState(null); const [como, setComo] = React.useState(() => fotosDe(p).length ? 'extra' : 'principal'); const [estado, setEstado] = React.useState({ t: '', tono: '' }); const [ocupado, setOcupado] = React.useState(false); const base = (window.MIPC_IMAGENES_BASE || {})[p.sku] || ''; const aplicar = (lista) => { window.MiPCFotos.aplicar(p.sku, lista); setFotos(lista.slice()); window.dispatchEvent(new CustomEvent('inventario-actualizado')); }; // La sesión del panel autoriza la publicación (api/fotos.php la revisa en el servidor) const conSesion = async (fn) => { setOcupado(true); setEstado({ t: 'Publicando…', tono: '' }); try { await fn(); setEstado({ t: 'Listo: ya se ve así en la tienda.', tono: 'ok' }); } catch (e) { setEstado({ t: e.message, tono: 'bad' }); } setOcupado(false); }; const elegir = async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (!f) return; try { setNueva(await prepararFoto(f)); setEstado({ t: '', tono: '' }); } catch (x) { setEstado({ t: x.message, tono: 'bad' }); } }; const publicar = () => conSesion(async () => { const j = await apiFotos('subir', { sku: p.sku, foto: nueva, base, como }); aplicar(j.fotos); setNueva(null); setComo('extra'); }); const ordenar = (lista) => conSesion(async () => { const j = await apiFotos('ordenar', { sku: p.sku, fotos: JSON.stringify(lista) }); aplicar(j.fotos); }); const principal = (i) => ordenar([fotos[i], ...fotos.filter((_, x) => x !== i)]); const quitar = (i) => { if (!confirm(i === 0 && fotos.length === 1 ? '¿Quitar la foto? El producto quedará sin foto en la tienda (el archivo no se borra del servidor).' : '¿Quitar esta foto de la tienda? (el archivo no se borra del servidor)')) return; ordenar(fotos.filter((_, x) => x !== i)); }; if (!p.sku) return
Este producto no tiene código: no se le pueden poner fotos.
; const puede = PANEL.puede('fotos.editar'); return (
{fotos.length > 0 ? (
{fotos.map((src, i) => (
{'Foto
{i === 0 ? Principal : }
))}
) : (
Este producto aún no tiene foto en la tienda.
)} {nueva && (
Foto nueva
Foto nueva Así quedará: cuadrada, fondo blanco, 900×900. {fotos.length > 0 && (
)}
)} {estado.t &&
{estado.t}
}
Las fotos se publican de inmediato en la tienda. "Quitar" la saca de la tienda pero el archivo queda guardado en el servidor.
); } // ── Producto (modal): datos de la hoja + contenido web + fotos ───────────── const unidadesTxt = (n) => n >= 20 ? '20 o más' : String(n || 0); const FICHA_EJEMPLO = 'Procesador: Intel Core i5 de 12.ª generación\nMemoria RAM: 8 GB\nAlmacenamiento: SSD 512 GB\nPantalla: 15,6" Full HD\nGarantía: 1 año'; function ContenidoWeb({ p }) { const puede = PANEL.puede('contenido.editar'); const [f, setF] = React.useState(null); // null = cargando const [meta, setMeta] = React.useState(null); const [msg, setMsg] = React.useState({ t: '', tono: '' }); const [ocupado, setOcupado] = React.useState(false); const set = (k, v) => setF(s => ({ ...s, [k]: v })); React.useEffect(() => { let vivo = true; apiGet(PANEL_API, 'producto', { sku: p.sku }) .then(j => { if (!vivo) return; const w = j.producto || {}; setF({ marca: w.marca || '', descripcion: w.descripcion || '', ficha: w.ficha || '', oculto: !!Number(w.oculto), destacado: !!Number(w.destacado) }); setMeta(w.actualizado ? { cuando: w.actualizado, quien: w.actualizado_por } : null); }) .catch(e => { if (vivo) { setF({ marca: '', descripcion: '', ficha: '', oculto: false, destacado: false }); setMsg({ t: e.message, tono: 'bad' }); } }); return () => { vivo = false; }; }, [p.sku]); const guardar = async () => { setOcupado(true); setMsg({ t: 'Guardando…', tono: '' }); try { await apiPost(PANEL_API, 'producto_guardar', { sku: p.sku, marca: f.marca, descripcion: f.descripcion, ficha: f.ficha, oculto: f.oculto ? '1' : '0', destacado: f.destacado ? '1' : '0' }); await window.MiPCCatalogo.cargarContenido(true); setMeta({ cuando: new Date().toISOString(), quien: PANEL.sesion.usuario }); setMsg({ t: f.oculto ? 'Guardado. El producto quedó OCULTO en la tienda.' : 'Guardado: ya se ve así en la tienda.', tono: 'ok' }); } catch (e) { setMsg({ t: e.message, tono: 'bad' }); } setOcupado(false); }; if (!f) return
Cargando contenido web…
; return (
{meta && Editado por {meta.quien} · {fechaCorta(meta.cuando)}}
set('marca', e.target.value)} placeholder="Ej: HP, Lenovo, Epson" maxLength={60} />