// 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 (
);
}
// ── 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 (
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') &&
Actualizar ahora }
{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 && (
go('productos', null, 'sin')}> Ver los {r.total - conFoto} productos sin foto →
)}
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 (
go('productos', c.id)} title="Ver en productos">
{c.nombre} {n}
);
})}
{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 en la tienda ({fotos.length ? fotos.length + (fotos.length === 1 ? ' foto' : ' fotos') : 'sin foto'})
Agregar foto
{fotos.length > 0 ? (
{fotos.map((src, i) => (
{i === 0 ? Principal
: principal(i)}>Hacer principal }
quitar(i)}>Quitar
))}
) : (
Este producto aún no tiene foto en la tienda.
)}
{nueva && (
)}
{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 (
Contenido en la tienda
{meta && Editado por {meta.quien} · {fechaCorta(meta.cuando)} }
Descripción
);
}
function ProductoModal({ id, onClose }) {
useInventario();
const p = INV().porId(id);
if (!p) return null;
const cat = INV().categorias.find(c => c.id === p.categoria);
return (
{ if (e.target === e.currentTarget) onClose(); }}>
{p.nombre}
Código {p.sku}
Categoría en la tienda {cat ? cat.nombre : p.categoria}
Precio {p.precio > 0 ? fmtCOP(p.precio) : 'Sin precio en la hoja'}
Unidades {unidadesTxt(p.stock)}
Nombre, precio, categoría y unidades se cambian en la Plataforma (Inventario); la tienda los toma de la hoja sola cada 10 minutos.
Cerrar
);
}
// ── Productos (consulta + fotos) ──────────────────────────────────────
function AdminProductos({ initialCat, initialFoto }) {
useInventario();
const [q, setQ] = React.useState('');
const [cat, setCat] = React.useState(initialCat || 'todas');
const [foto, setFoto] = React.useState(initialFoto || 'todas'); // todas | con | sin
const [web, setWeb] = React.useState('todos'); // todos | ocultos | destacados
const [modal, setModal] = React.useState(null); // null | id
const cats = INV().categorias;
let list = INV().productos;
if (cat !== 'todas') list = list.filter(p => p.categoria === cat || (p.extras || []).includes(cat));
if (foto !== 'todas') list = list.filter(p => (fotosDe(p).length > 0) === (foto === 'con'));
if (web === 'ocultos') list = list.filter(p => p.oculto);
if (web === 'destacados') list = list.filter(p => p.destacado);
const s = q.trim().toLowerCase();
if (s) list = list.filter(p => (p.nombre + ' ' + p.marca + ' ' + (p.sku || '')).toLowerCase().includes(s));
return (
Productos {list.length}
Nombre, precio y unidades vienen de la hoja INVENTARIO. Toca un producto para editar su contenido en la tienda (marca, descripción, ficha) y sus fotos.
Producto Categoría Precio
Unidades Fotos
{list.slice(0, 300).map(p => (
0) ? 'adm-row-out' : ((p.stock <= 5) ? 'adm-row-low' : '')} onClick={() => setModal(p.id)}>
{fotosDe(p).length
?
:
}
{p.nombre}
{marcaVisible(p.marca) ? p.marca + ' · ' : ''}{p.sku}
{p.oculto && Oculto }
{p.destacado && Destacado }
{cats.find(c => c.id === p.categoria)?.nombre || p.categoria}
{p.precio > 0 ? fmtCOP(p.precio) : Sin precio }
{unidadesTxt(p.stock)}
{ e.stopPropagation(); setModal(p.id); }}>
))}
{!list.length &&
{INV().estado === 'listo' ? 'No hay productos que coincidan.' : INV().estado === 'error' ? 'No se pudo cargar el catálogo.' : 'Cargando catálogo…'}
}
{list.length > 300 &&
Mostrando los primeros 300. Afina la búsqueda o la categoría.
}
{modal &&
setModal(null)} />}
);
}
// ── Inventario (consulta: sin unidades / pocas unidades) ─────────────────
function AdminInventario() {
useInventario();
const r = INV().resumen();
const Fila = ({ p }) => (
{p.nombre} {p.sku}
0 ? 'low' : 'out')}>{p.stock}
);
return (
Inventario
Unidades tal como están en la hoja INVENTARIO (suma de Sede, Sucursal y Bodega). Se cambian en la Plataforma; aquí solo se consultan.
Pocas unidades ({r.pocas.length})
{r.pocas.length ? r.pocas.map(p =>
) :
Nada con pocas unidades.
}
Sin unidades ({r.agotados.length})
{r.agotados.length ? r.agotados.slice(0, 80).map(p =>
) :
Nada sin unidades.
}
);
}
// ── Usuarios (solo administrador) ─────────────────────────────────────
const ROL_DESC = {
admin: 'Todo: usuarios, actividad, contenido web, fotos y actualizar el catálogo.',
supervision: 'Contenido web de los productos (marca, descripción, ficha, ocultar y destacar), fotos y actualizar el catálogo. No ve usuarios ni actividad.',
};
function AdminUsuarios() {
const [lista, setLista] = React.useState(null);
const [error, setError] = React.useState('');
const [form, setForm] = React.useState(null); // null | { nuevo, usuario, nombre, rol, pass, activo }
const [msg, setMsg] = React.useState('');
const [ocupado, setOcupado] = React.useState(false);
const roles = PANEL.roles || {};
const yo = PANEL.sesion && PANEL.sesion.usuario;
const cargar = () => apiGet(PANEL_API, 'usuarios').then(j => { setLista(j.usuarios); setError(''); }).catch(e => setError(e.message));
React.useEffect(() => { cargar(); }, []);
const guardar = async () => {
setOcupado(true); setMsg('');
try {
await apiPost(PANEL_API, 'usuario_guardar', { nuevo: form.nuevo ? '1' : '0', usuario: form.usuario.trim().toLowerCase(),
nombre: form.nombre.trim(), rol: form.rol, pass: form.pass, activo: form.activo ? '1' : '0' });
setForm(null); cargar();
} catch (e) { setMsg(e.message); }
setOcupado(false);
};
return (
Usuarios
{ setMsg(''); setForm({ nuevo: true, usuario: '', nombre: '', rol: 'supervision', pass: '', activo: true }); }}>+ Nuevo usuario
{error &&
{error}
}
Usuario Nombre Rol Último ingreso Estado
{(lista || []).map(u => (
{ setMsg(''); setForm({ nuevo: false, usuario: u.usuario, nombre: u.nombre, rol: u.rol, pass: '', activo: !!Number(u.activo) }); }}>
{u.usuario} {yo === u.usuario && tú }
{u.nombre}
{roles[u.rol] || u.rol}
{fechaCorta(u.ultimo_ingreso)}
{Number(u.activo) ? 'Activo' : 'Desactivado'}
))}
{!lista && !error &&
Cargando…
}
Qué puede hacer cada rol
{Object.keys(ROL_DESC).map(r => (
{roles[r] || r}
{ROL_DESC[r]}
))}
{form && (
{ if (e.target === e.currentTarget) setForm(null); }}>
{form.nuevo ? 'Nuevo usuario' : 'Editar usuario'}
setForm(null)}>
setForm(null)}>Cancelar
Guardar
)}
);
}
// ── Actividad (solo administrador): quién cambió qué ──────────────────
const ACCIONES = {
'sesion.entrar': 'Ingresó al panel', 'sesion.salir': 'Salió del panel',
'usuario.crear': 'Creó un usuario', 'usuario.editar': 'Editó un usuario',
'producto.contenido': 'Editó el contenido web', 'foto.subir': 'Publicó una foto', 'foto.ordenar': 'Cambió las fotos',
};
function AdminActividad() {
const [lista, setLista] = React.useState(null);
const [error, setError] = React.useState('');
React.useEffect(() => { apiGet(PANEL_API, 'bitacora').then(j => setLista(j.bitacora)).catch(e => setError(e.message)); }, []);
const nombreDe = (sku) => { const p = INV().porId(sku); return p ? p.nombre : ''; };
return (
Actividad
Últimos 150 cambios hechos desde el panel.
{error &&
{error}
}
Cuándo Quién Qué Sobre
{(lista || []).map((b, i) => (
{fechaCorta(b.ts)}
{b.usuario}
{ACCIONES[b.accion] || b.accion}{b.detalle ? {b.detalle}
: null}
{b.objeto}{nombreDe(b.objeto) ? {nombreDe(b.objeto)}
: null}
))}
{!lista && !error &&
Cargando…
}
{lista && !lista.length &&
Aún no hay actividad.
}
);
}
// ── Shell del panel ───────────────────────────────────────────────────
function AdminShell({ onNavigate, onSalir }) {
const ses = PANEL.sesion;
const [tab, setTab] = React.useState('dashboard');
const [prodCat, setProdCat] = React.useState(null);
const [prodFoto, setProdFoto] = React.useState(null);
const go = (t, cat, foto) => { setProdCat(cat || null); setProdFoto(foto || null); setTab(t); };
const nav = [
{ id: 'dashboard', label: 'Resumen', ic: , perm: null },
{ id: 'productos', label: 'Productos', ic: , perm: 'productos.ver' },
{ id: 'inventario', label: 'Inventario', ic: , perm: 'productos.ver' },
{ id: 'usuarios', label: 'Usuarios', ic: , perm: 'usuarios' },
{ id: 'actividad', label: 'Actividad', ic: , perm: 'actividad' },
].filter(n => !n.perm || PANEL.puede(n.perm));
const salir = async () => { try { await apiPost(PANEL_API, 'salir', {}); } catch (e) {} onSalir(); };
return (
onNavigate('/')}>Mi PC gestión
{nav.map(n => (
go(n.id)}>
{n.ic} {n.label}
))}
{ses.nombre} {ses.rolNombre}
onNavigate('/')}>Ver tienda ↗
Cerrar sesión
{tab === 'dashboard' && }
{tab === 'productos' && }
{tab === 'inventario' && }
{tab === 'usuarios' && }
{tab === 'actividad' && }
);
}
// ── Entrada: pregunta al servidor si hay sesión ───────────────────────
function AdminPage({ onNavigate }) {
const [est, setEst] = React.useState({ cargando: true });
const revisar = React.useCallback(async () => {
try {
const j = await apiGet(PANEL_API, 'estado');
PANEL.sesion = j.sesion || null;
PANEL.roles = j.roles || {};
setEst({ necesitaAdmin: !!j.necesitaAdmin, sesion: j.sesion || null });
} catch (e) { setEst({ error: e.message }); }
}, []);
React.useEffect(() => {
revisar();
const h = () => revisar();
window.addEventListener('panel-sesion-terminada', h);
return () => window.removeEventListener('panel-sesion-terminada', h);
}, [revisar]);
if (est.cargando) return ;
if (est.error) return (
No se pudo abrir el panel
{est.error}
{ setEst({ cargando: true }); revisar(); }}>Reintentar
onNavigate('/')}>← Volver a la tienda
);
if (est.necesitaAdmin) return ;
if (!est.sesion) return ;
return { PANEL.sesion = null; setEst({ sesion: null }); }} />;
}
window.AdminPage = AdminPage;