pestana descargas y subcategorias funcionando
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -126,6 +126,102 @@ async function saveCategorias(categorias) {
|
|||||||
await writeJson('categorias.json', categorias);
|
await writeJson('categorias.json', categorias);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════
|
||||||
|
// TREE HELPERS — para subcategorías anidadas
|
||||||
|
// Estructura: cada categoría puede tener un array opcional `subcategorias`
|
||||||
|
// con la misma forma, recursivamente. Máximo 3 niveles (cat > sub > sub-sub).
|
||||||
|
// ════════════════════════════════
|
||||||
|
function findCategoryById(categorias, id) {
|
||||||
|
for (const c of categorias) {
|
||||||
|
if (c.id === id) return c;
|
||||||
|
if (Array.isArray(c.subcategorias) && c.subcategorias.length) {
|
||||||
|
const found = findInSubcats(c.subcategorias, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInSubcats(subcats, id) {
|
||||||
|
for (const s of subcats) {
|
||||||
|
if (s.id === id) return s;
|
||||||
|
if (Array.isArray(s.subcategorias) && s.subcategorias.length) {
|
||||||
|
const found = findInSubcats(s.subcategorias, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve la ruta desde la raíz hasta el nodo con ese id.
|
||||||
|
// Ej: para "morrales" en bolsos>hombre>morrales → [{id:bolsos}, {id:hombre}, {id:morrales}]
|
||||||
|
function getCategoryPath(categorias, id, trail = []) {
|
||||||
|
for (const c of categorias) {
|
||||||
|
const next = [...trail, c];
|
||||||
|
if (c.id === id) return next;
|
||||||
|
if (Array.isArray(c.subcategorias) && c.subcategorias.length) {
|
||||||
|
const found = getCategoryPath(c.subcategorias, id, next);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recolecta todos los ids de subcategorías dentro de un array de subcats.
|
||||||
|
// Usado para validar duplicados al crear una nueva subcategoría.
|
||||||
|
function collectSubcatIds(subcats, acc = new Set()) {
|
||||||
|
if (!Array.isArray(subcats)) return acc;
|
||||||
|
for (const s of subcats) {
|
||||||
|
if (s.id) acc.add(s.id);
|
||||||
|
if (Array.isArray(s.subcategorias)) collectSubcatIds(s.subcategorias, acc);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quita una subcategoría del árbol (modifica in place). Devuelve true si la encontró.
|
||||||
|
function removeSubcatFromTree(categorias, id) {
|
||||||
|
for (const c of categorias) {
|
||||||
|
if (Array.isArray(c.subcategorias)) {
|
||||||
|
const idx = c.subcategorias.findIndex(s => s.id === id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
c.subcategorias.splice(idx, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (removeSubcatFromTree(c.subcategorias, id)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca una subcat por id en un array de subcats (cualquier nivel) y la devuelve, o null.
|
||||||
|
function findSubcatRecursive(subcats, id) {
|
||||||
|
if (!Array.isArray(subcats)) return null;
|
||||||
|
for (const s of subcats) {
|
||||||
|
if (s.id === id) return s;
|
||||||
|
if (Array.isArray(s.subcategorias)) {
|
||||||
|
const found = findSubcatRecursive(s.subcategorias, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quita una subcat de un array de subcats (cualquier nivel) y la devuelve. O null si no está.
|
||||||
|
function removeSubcatRecursive(subcats, id) {
|
||||||
|
if (!Array.isArray(subcats)) return null;
|
||||||
|
for (let i = 0; i < subcats.length; i++) {
|
||||||
|
if (subcats[i].id === id) {
|
||||||
|
const [removed] = subcats.splice(i, 1);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
if (Array.isArray(subcats[i].subcategorias)) {
|
||||||
|
const found = removeSubcatRecursive(subcats[i].subcategorias, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureSinCategoria() {
|
async function ensureSinCategoria() {
|
||||||
const categorias = await getCategorias();
|
const categorias = await getCategorias();
|
||||||
if (!categorias.some(c => c.id === SIN_CATEGORIA_ID)) {
|
if (!categorias.some(c => c.id === SIN_CATEGORIA_ID)) {
|
||||||
@@ -549,6 +645,131 @@ app.delete('/api/categorias/:id', requireCategoriasManagement, async (req, res)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════
|
||||||
|
// SUBCATEGORÍAS (anidadas, máx 3 niveles)
|
||||||
|
// CRUD separado del de categorías para validaciones más finas.
|
||||||
|
// No afecta productos todavía (esa integración viene en el siguiente paso).
|
||||||
|
// ════════════════════════════════
|
||||||
|
|
||||||
|
// POST /api/categorias/:id/subcategorias → crear subcat dentro de :id
|
||||||
|
// Body: { nombre, parentSubId? }
|
||||||
|
// - Sin parentSubId: crea subcat nivel 1 (directo dentro de la categoría)
|
||||||
|
// - Con parentSubId: crea sub-subcat (nivel 2) dentro de la subcat indicada
|
||||||
|
app.post('/api/categorias/:id/subcategorias', requireCategoriasManagement, async (req, res) => {
|
||||||
|
const parentId = req.params.id;
|
||||||
|
const { nombre, parentSubId } = req.body || {};
|
||||||
|
|
||||||
|
if (!nombre || !String(nombre).trim() || String(nombre).length > 40) {
|
||||||
|
return res.status(400).json({ error: 'Nombre inválido (1-40 caracteres)' });
|
||||||
|
}
|
||||||
|
if (parentId === SIN_CATEGORIA_ID) {
|
||||||
|
return res.status(400).json({ error: 'No se pueden agregar subcategorías a "Sin categoría"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categorias = await getCategorias();
|
||||||
|
const parent = categorias.find(c => c.id === parentId);
|
||||||
|
if (!parent) return res.status(404).json({ error: 'Categoría padre no encontrada' });
|
||||||
|
|
||||||
|
// Determinar el array destino: subcategorías de la categoría, o de la sub-sub
|
||||||
|
let targetArray;
|
||||||
|
if (parentSubId) {
|
||||||
|
// Creando sub-sub: el "padre" es una subcat existente
|
||||||
|
const subParent = Array.isArray(parent.subcategorias)
|
||||||
|
? parent.subcategorias.find(s => s.id === parentSubId)
|
||||||
|
: null;
|
||||||
|
if (!subParent) return res.status(404).json({ error: 'Subcategoría padre no encontrada' });
|
||||||
|
if (!Array.isArray(subParent.subcategorias)) subParent.subcategorias = [];
|
||||||
|
targetArray = subParent.subcategorias;
|
||||||
|
} else {
|
||||||
|
// Creando subcat nivel 1
|
||||||
|
if (!Array.isArray(parent.subcategorias)) parent.subcategorias = [];
|
||||||
|
targetArray = parent.subcategorias;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Límite de profundidad: máximo 3 niveles (cat > sub > sub-sub).
|
||||||
|
// Si estamos creando nivel 1 y la categoría ya tiene sub-subcats (de otras ramas),
|
||||||
|
// todavía está OK (cada rama puede tener sub-sub independientes).
|
||||||
|
// Lo que NO permitimos es crear un nivel 3 (porque la URL no lo soporta y la UI no lo ofrece).
|
||||||
|
|
||||||
|
// Generar id (slug) y validar duplicado dentro del padre directo
|
||||||
|
const slug = slugify(nombre);
|
||||||
|
if (!slug) return res.status(400).json({ error: 'Nombre inválido' });
|
||||||
|
|
||||||
|
const idsHermanos = new Set(targetArray.map(s => s.id));
|
||||||
|
if (idsHermanos.has(slug)) {
|
||||||
|
return res.status(409).json({ error: `Ya existe una subcategoría "${slug}" aquí` });
|
||||||
|
}
|
||||||
|
|
||||||
|
targetArray.push({ id: slug, nombre: String(nombre).trim() });
|
||||||
|
await saveCategorias(categorias);
|
||||||
|
|
||||||
|
res.json({ ok: true, subcategoria: { id: slug, nombre: String(nombre).trim() } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create subcategory error:', err);
|
||||||
|
res.status(500).json({ error: 'Error al crear subcategoría' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/categorias/:id/subcategorias/:subId → editar nombre (id inmutable)
|
||||||
|
// Busca recursivamente en el árbol: :id es la categoría raíz, :subId puede estar en cualquier nivel.
|
||||||
|
app.put('/api/categorias/:id/subcategorias/:subId', requireCategoriasManagement, async (req, res) => {
|
||||||
|
const { id: parentId, subId } = req.params;
|
||||||
|
const { nombre } = req.body || {};
|
||||||
|
|
||||||
|
if (!nombre || !String(nombre).trim() || String(nombre).length > 40) {
|
||||||
|
return res.status(400).json({ error: 'Nombre inválido (1-40 caracteres)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categorias = await getCategorias();
|
||||||
|
const parent = categorias.find(c => c.id === parentId);
|
||||||
|
if (!parent) return res.status(404).json({ error: 'Categoría padre no encontrada' });
|
||||||
|
|
||||||
|
const sub = findSubcatRecursive(parent.subcategorias, subId);
|
||||||
|
if (!sub) return res.status(404).json({ error: 'Subcategoría no encontrada' });
|
||||||
|
|
||||||
|
sub.nombre = String(nombre).trim();
|
||||||
|
await saveCategorias(categorias);
|
||||||
|
res.json({ ok: true, subcategoria: { id: sub.id, nombre: sub.nombre } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Edit subcategory error:', err);
|
||||||
|
res.status(500).json({ error: 'Error al editar subcategoría' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/categorias/:id/subcategorias/:subId → eliminar (mover productos al padre)
|
||||||
|
// Solo se permite borrar hojas (subcats sin sub-subcats). Si tiene sub-subcats, debe borrarlas primero.
|
||||||
|
app.delete('/api/categorias/:id/subcategorias/:subId', requireCategoriasManagement, async (req, res) => {
|
||||||
|
const { id: parentId, subId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categorias = await getCategorias();
|
||||||
|
const parent = categorias.find(c => c.id === parentId);
|
||||||
|
if (!parent) return res.status(404).json({ error: 'Categoría padre no encontrada' });
|
||||||
|
|
||||||
|
const sub = findSubcatRecursive(parent.subcategorias, subId);
|
||||||
|
if (!sub) return res.status(404).json({ error: 'Subcategoría no encontrada' });
|
||||||
|
|
||||||
|
if (Array.isArray(sub.subcategorias) && sub.subcategorias.length) {
|
||||||
|
return res.status(400).json({ error: 'Primero elimina las sub-subcategorías' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quitar del árbol (recursivo, encuentra el padre correcto y splice)
|
||||||
|
removeSubcatRecursive(parent.subcategorias, subId);
|
||||||
|
// Mantener array limpio: si la raíz quedó sin subcats, eliminamos la propiedad
|
||||||
|
if (Array.isArray(parent.subcategorias) && parent.subcategorias.length === 0) {
|
||||||
|
delete parent.subcategorias;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveCategorias(categorias);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete subcategory error:', err);
|
||||||
|
res.status(500).json({ error: 'Error al eliminar subcategoría' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ════════════════════════════════
|
// ════════════════════════════════
|
||||||
// GESTIÓN DE USUARIOS (admin o permiso 'usuarios')
|
// GESTIÓN DE USUARIOS (admin o permiso 'usuarios')
|
||||||
// ════════════════════════════════
|
// ════════════════════════════════
|
||||||
@@ -718,6 +939,7 @@ function parseFilename(name) {
|
|||||||
|
|
||||||
app.post('/api/productos/upload', requireProductosManagement, upload.array('fotos', MAX_FILES), async (req, res) => {
|
app.post('/api/productos/upload', requireProductosManagement, upload.array('fotos', MAX_FILES), async (req, res) => {
|
||||||
const categoria = (req.body.categoria || '').trim();
|
const categoria = (req.body.categoria || '').trim();
|
||||||
|
const subRaw = req.body.sub || '[]';
|
||||||
const files = req.files || [];
|
const files = req.files || [];
|
||||||
|
|
||||||
if (!categoria) {
|
if (!categoria) {
|
||||||
@@ -727,6 +949,15 @@ app.post('/api/productos/upload', requireProductosManagement, upload.array('foto
|
|||||||
return res.status(400).json({ error: 'No se enviaron fotos' });
|
return res.status(400).json({ error: 'No se enviaron fotos' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parsear la cadena de subcategorías (viene como JSON string desde el form data)
|
||||||
|
let subPath;
|
||||||
|
try {
|
||||||
|
subPath = JSON.parse(subRaw);
|
||||||
|
if (!Array.isArray(subPath)) throw new Error();
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Subcategoría inválida' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verificar que la categoría existe y NO es sin-categoria
|
// Verificar que la categoría existe y NO es sin-categoria
|
||||||
const categorias = await getCategorias();
|
const categorias = await getCategorias();
|
||||||
@@ -738,8 +969,46 @@ app.post('/api/productos/upload', requireProductosManagement, upload.array('foto
|
|||||||
return res.status(400).json({ error: 'No se puede subir productos a la categoría del sistema' });
|
return res.status(400).json({ error: 'No se puede subir productos a la categoría del sistema' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear carpeta destino si no existe
|
// Validar la cadena de subcats contra el árbol:
|
||||||
const dstDir = path.join(IMG_DIR, categoria);
|
// - La categoría tiene subcats → subPath DEBE tener al menos 1 elemento (la hoja final)
|
||||||
|
// - La categoría NO tiene subcats → subPath debe estar vacío
|
||||||
|
// - Cada sub debe existir en su nivel y el último debe ser HOJA (sin sub-subs)
|
||||||
|
const catTieneSubcats = Array.isArray(cat.subcategorias) && cat.subcategorias.length > 0;
|
||||||
|
|
||||||
|
if (catTieneSubcats && subPath.length === 0) {
|
||||||
|
return res.status(400).json({ error: `"${cat.nombre}" tiene subcategorías, debes elegir una hoja` });
|
||||||
|
}
|
||||||
|
if (!catTieneSubcats && subPath.length > 0) {
|
||||||
|
return res.status(400).json({ error: `"${cat.nombre}" no tiene subcategorías` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caminar el árbol validando cada nivel
|
||||||
|
let nivelActual = cat.subcategorias || [];
|
||||||
|
for (let i = 0; i < subPath.length; i++) {
|
||||||
|
const id = subPath[i];
|
||||||
|
const nodo = nivelActual.find(s => s.id === id);
|
||||||
|
if (!nodo) {
|
||||||
|
return res.status(400).json({ error: `Subcategoría "${id}" no encontrada en nivel ${i + 1}` });
|
||||||
|
}
|
||||||
|
const esUltimo = i === subPath.length - 1;
|
||||||
|
if (esUltimo) {
|
||||||
|
// La hoja final NO debe tener sub-subcategorías (solo las hojas reciben productos)
|
||||||
|
if (Array.isArray(nodo.subcategorias) && nodo.subcategorias.length > 0) {
|
||||||
|
return res.status(400).json({ error: `"${nodo.nombre}" tiene sub-subcategorías, debes elegir una de ellas` });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No es el último → debe tener subcategorías para seguir bajando
|
||||||
|
if (!Array.isArray(nodo.subcategorias) || nodo.subcategorias.length === 0) {
|
||||||
|
return res.status(400).json({ error: `"${nodo.nombre}" no tiene sub-subcategorías` });
|
||||||
|
}
|
||||||
|
nivelActual = nodo.subcategorias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir el path destino: <cat> o <cat>/<sub1> o <cat>/<sub1>/<sub2>
|
||||||
|
const pathParts = [categoria, ...subPath];
|
||||||
|
const relDir = pathParts.join('/');
|
||||||
|
const dstDir = path.join(IMG_DIR, relDir);
|
||||||
await fs.mkdir(dstDir, { recursive: true });
|
await fs.mkdir(dstDir, { recursive: true });
|
||||||
|
|
||||||
// Cargar productos existentes
|
// Cargar productos existentes
|
||||||
@@ -785,7 +1054,8 @@ app.post('/api/productos/upload', requireProductosManagement, upload.array('foto
|
|||||||
id: ref,
|
id: ref,
|
||||||
ref: ref,
|
ref: ref,
|
||||||
cat: categoria,
|
cat: categoria,
|
||||||
img: `${categoria}/${outName}`
|
sub: subPath,
|
||||||
|
img: `${relDir}/${outName}`
|
||||||
});
|
});
|
||||||
creados.push({ ref });
|
creados.push({ ref });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -7,6 +7,22 @@
|
|||||||
{
|
{
|
||||||
"id": "bolsos",
|
"id": "bolsos",
|
||||||
"nombre": "BOLSOS",
|
"nombre": "BOLSOS",
|
||||||
"icono": "👜"
|
"icono": "👜",
|
||||||
|
"subcategorias": [
|
||||||
|
{
|
||||||
|
"id": "dama",
|
||||||
|
"nombre": "DAMA",
|
||||||
|
"subcategorias": [
|
||||||
|
{
|
||||||
|
"id": "aaa",
|
||||||
|
"nombre": "BILLETERAS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bolso-viajero",
|
||||||
|
"nombre": "BOLSO VIAJERO"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,54 +1,262 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"id": "AF100-124",
|
|
||||||
"ref": "AF100-124",
|
|
||||||
"cat": "bolsos",
|
|
||||||
"img": "agotados/AF100-124.webp",
|
|
||||||
"nombre": "Bolso de cuero negro estilo clásico",
|
|
||||||
"precio": 50000
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "AF100-128",
|
"id": "AF100-128",
|
||||||
"ref": "AF100-128",
|
"ref": "AF100-128",
|
||||||
"cat": "bolsos",
|
"cat": "sin-categoria",
|
||||||
"img": "bolsos/AF100-128.webp",
|
"img": "sin-categoria/AF100-128.webp",
|
||||||
"nombre": "Bolso de mano elegante con detalles dorados",
|
"nombre": "Bolso de mano elegante con detalles dorados",
|
||||||
"precio": 75000
|
"precio": 75000,
|
||||||
|
"sub": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "AF100-129",
|
"id": "AF100-129",
|
||||||
"ref": "AF100-129",
|
"ref": "AF100-129",
|
||||||
"cat": "bolsos",
|
"cat": "sin-categoria",
|
||||||
"img": "bolsos/AF100-129.webp",
|
"img": "sin-categoria/AF100-129.webp",
|
||||||
"nombre": "Bolso deportivo resistente al agua",
|
"nombre": "Bolso deportivo resistente al agua",
|
||||||
"precio": 45000.5
|
"precio": 45000.5,
|
||||||
|
"sub": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "AF100-159",
|
"id": "AF100-159",
|
||||||
"ref": "AF100-159",
|
"ref": "AF100-159",
|
||||||
"cat": "bolsos",
|
"cat": "sin-categoria",
|
||||||
"img": "bolsos/AF100-159.webp",
|
"img": "sin-categoria/AF100-159.webp",
|
||||||
"precio": 85000
|
"precio": 85000,
|
||||||
|
"sub": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "AF100-171",
|
"id": "AF100-171",
|
||||||
"ref": "AF100-171",
|
"ref": "AF100-171",
|
||||||
"cat": "bolsos",
|
"cat": "sin-categoria",
|
||||||
"img": "bolsos/AF100-171.webp",
|
"img": "sin-categoria/AF100-171.webp",
|
||||||
"nombre": "Mochila urbana minimalista"
|
"nombre": "Mochila urbana minimalista",
|
||||||
|
"sub": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "AF100-174",
|
"id": "AF100-174",
|
||||||
"ref": "AF100-174",
|
"ref": "AF100-174",
|
||||||
"cat": "bolsos",
|
"cat": "sin-categoria",
|
||||||
"img": "bolsos/AF100-174.webp",
|
"img": "sin-categoria/AF100-174.webp",
|
||||||
"nombre": "Set de bolsos combinados (3 piezas)",
|
"nombre": "Set de bolsos combinados (3 piezas)",
|
||||||
"precio": 120000
|
"precio": 120000,
|
||||||
|
"sub": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "AF9173",
|
"id": "AF9173",
|
||||||
"ref": "AF9173",
|
"ref": "AF9173",
|
||||||
|
"cat": "sin-categoria",
|
||||||
|
"img": "sin-categoria/AF9173.webp",
|
||||||
|
"sub": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-197",
|
||||||
|
"ref": "AF100-197",
|
||||||
"cat": "bolsos",
|
"cat": "bolsos",
|
||||||
"img": "bolsos/AF9173.webp"
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-197.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-198",
|
||||||
|
"ref": "AF100-198",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-198.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-199",
|
||||||
|
"ref": "AF100-199",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-199.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-201",
|
||||||
|
"ref": "AF100-201",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-201.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-202",
|
||||||
|
"ref": "AF100-202",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-202.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-208",
|
||||||
|
"ref": "AF100-208",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-208.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-209",
|
||||||
|
"ref": "AF100-209",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-209.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-210",
|
||||||
|
"ref": "AF100-210",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-210.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-216",
|
||||||
|
"ref": "AF100-216",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-216.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-219",
|
||||||
|
"ref": "AF100-219",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-219.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-223",
|
||||||
|
"ref": "AF100-223",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-223.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF100-224",
|
||||||
|
"ref": "AF100-224",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF100-224.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF9170",
|
||||||
|
"ref": "AF9170",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/AF9170.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MG-067",
|
||||||
|
"ref": "MG-067",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/MG-067.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TEST-A",
|
||||||
|
"ref": "TEST-A",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/TEST-A.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TEST-B",
|
||||||
|
"ref": "TEST-B",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/TEST-B.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TEST-C",
|
||||||
|
"ref": "TEST-C",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/TEST-C.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TEST-D",
|
||||||
|
"ref": "TEST-D",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"aaa"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/aaa/TEST-D.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF2296",
|
||||||
|
"ref": "AF2296",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"bolso-viajero"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/bolso-viajero/AF2296.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF9013",
|
||||||
|
"ref": "AF9013",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"bolso-viajero"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/bolso-viajero/AF9013.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AF9391",
|
||||||
|
"ref": "AF9391",
|
||||||
|
"cat": "bolsos",
|
||||||
|
"sub": [
|
||||||
|
"dama",
|
||||||
|
"bolso-viajero"
|
||||||
|
],
|
||||||
|
"img": "bolsos/dama/bolso-viajero/AF9391.webp"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,32 +1,74 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"token": "cf5d634b0f9ad09bfcc6370f57c45d2049b7336af476107cea3b5c1617df29e0",
|
"token": "2ac8079c7722e532838b6585cce4d4e2e1bc50e7dd6fc1ff822a526f1bc06464",
|
||||||
"usuario": "admin",
|
"usuario": "admin",
|
||||||
"rol": "admin",
|
"rol": "admin",
|
||||||
"createdAt": 1780718070269
|
"createdAt": 1780723402897
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"token": "18342f8390074dea906ffebf0f0a5e34feb6a27c9caf2c7c721542dd961d5d59",
|
"token": "730b165ea6232fd9fa4aea5bad281efb3ee67cc8eda392c20bfa754ad389840c",
|
||||||
"usuario": "admin",
|
"usuario": "admin",
|
||||||
"rol": "admin",
|
"rol": "admin",
|
||||||
"createdAt": 1780719045054
|
"createdAt": 1780723462669
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"token": "7e95e1adfdc6f90a52122d72e6e05d24bcafd4c9727be52976ea312a636ab2f5",
|
"token": "4c713be1e6cd7abe8f87511eb029ed44da6489869f5505fa1762e7650fbd3012",
|
||||||
"usuario": "vendetest",
|
|
||||||
"rol": "vendedor",
|
|
||||||
"createdAt": 1780719045099
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"token": "a9b4be06e4e40fe83595d388a8c08b9270df7557e2a2b63b793552609b2d2d71",
|
|
||||||
"usuario": "vendetest",
|
|
||||||
"rol": "vendedor",
|
|
||||||
"createdAt": 1780719061095
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"token": "4cb3d9aa79e1c83e87863a32d4cbbc9a2eafadaa08ba110964250121939b8d3a",
|
|
||||||
"usuario": "admin",
|
"usuario": "admin",
|
||||||
"rol": "admin",
|
"rol": "admin",
|
||||||
"createdAt": 1780719205299
|
"createdAt": 1780723483924
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "c8193a44c99f7fe374305c4bc78176f22cc349f255454274b80cb24a115f4277",
|
||||||
|
"usuario": "admin",
|
||||||
|
"rol": "admin",
|
||||||
|
"createdAt": 1780723677016
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "3885b53c92b5f05fb9380d7696a1d8de5dd387edd7cefd7e87461942c0282c0a",
|
||||||
|
"usuario": "admin",
|
||||||
|
"rol": "admin",
|
||||||
|
"createdAt": 1780723704606
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "dcd159dd861c6d36ddbd9ad7951c8bf3b528f5b6f0a88785cba1272efdccdabc",
|
||||||
|
"usuario": "admin",
|
||||||
|
"rol": "admin",
|
||||||
|
"createdAt": 1780723713354
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "23ecef9918a940972be4f1e5931a46c1cd6dd5f9b21e3cdc3f436d1b73db57f8",
|
||||||
|
"usuario": "admin",
|
||||||
|
"rol": "admin",
|
||||||
|
"createdAt": 1780723783896
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "0f91dcb676695fe8ae6c253e7e388c0337250657cbdedd036afd92a94735f610",
|
||||||
|
"usuario": "admin",
|
||||||
|
"rol": "admin",
|
||||||
|
"createdAt": 1780725080933
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "5d73537d981bc681cb9b28c1c6f6f0639d642d626e1feacf04061249cfbe5108",
|
||||||
|
"usuario": "admin",
|
||||||
|
"rol": "admin",
|
||||||
|
"createdAt": 1780725168543
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "9243ed349a1d8dc1c87a15048cee65a4f1758e290640812c71a948015ccc5150",
|
||||||
|
"usuario": "admin",
|
||||||
|
"rol": "admin",
|
||||||
|
"createdAt": 1780725735051
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "fe63e427d82b8bb2bf1593f696170ee4b77298302fa551db1980d32fc7f8f071",
|
||||||
|
"usuario": "admin",
|
||||||
|
"rol": "admin",
|
||||||
|
"createdAt": 1780726025468
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "901dc29fdf21ee6ac2a96fd6844fe956f898eaf748c3f9008a34362f1febc87c",
|
||||||
|
"usuario": "admin",
|
||||||
|
"rol": "admin",
|
||||||
|
"createdAt": 1780726303152
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -688,12 +688,12 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════
|
/* ════════════════════════════════
|
||||||
CATEGORÍAS
|
ESTILOS DE CATEGORÍAS (vista admin)
|
||||||
════════════════════════════════ */
|
════════════════════════════════ */
|
||||||
.categorias-grid {
|
.categorias-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||||
gap: 16px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.categoria-card {
|
.categoria-card {
|
||||||
@@ -1030,6 +1030,273 @@ select {
|
|||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════
|
||||||
|
ÁRBOL DE SUBCATEGORÍAS (dentro de la card de categoría)
|
||||||
|
Opción A: cards 2-col, acciones hover-reveal, menú kebab
|
||||||
|
════════════════════════════════ */
|
||||||
|
.categoria-card {
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible; /* allow kebab menus to escape the card */
|
||||||
|
display: block; /* override inherited flex-column from base rule */
|
||||||
|
text-align: left; /* override inherited text-align: center */
|
||||||
|
align-items: stretch; /* override inherited align-items: center */
|
||||||
|
z-index: 1; /* hover card stays above siblings */
|
||||||
|
}
|
||||||
|
.categoria-card.menu-host { z-index: 50; } /* card with open menu */
|
||||||
|
|
||||||
|
.categoria-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoria-header .categoria-icon { font-size: 1.9rem; line-height: 1; }
|
||||||
|
.categoria-header .categoria-meta { min-width: 0; }
|
||||||
|
.categoria-header .categoria-nombre {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.categoria-header .categoria-id {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.categoria-header .categoria-count {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Acciones de categoría: solo contienen el ⋯ (todo lo demás está dentro del menú) */
|
||||||
|
.categoria-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subcat-tree {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom-left-radius: calc(var(--radius-md) - 1.5px);
|
||||||
|
border-bottom-right-radius: calc(var(--radius-md) - 1.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subcat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 18px 8px 44px;
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
transition: background .15s ease;
|
||||||
|
}
|
||||||
|
.subcat-row:hover { background: rgba(255,255,255,0.55); }
|
||||||
|
.subcat-row::before {
|
||||||
|
content: '└';
|
||||||
|
position: absolute;
|
||||||
|
left: 28px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--ink-mute);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subcat-children .subcat-row { padding-left: 72px; }
|
||||||
|
.subcat-children .subcat-row::before { left: 56px; }
|
||||||
|
|
||||||
|
.subcat-info { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||||
|
.subcat-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.subcat-id {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Acciones de subcat: solo contienen el ⋯ */
|
||||||
|
.subcat-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-tiny {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all .15s ease;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-icon-tiny:hover {
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.btn-icon-tiny.btn-icon-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════
|
||||||
|
MENÚ KEBAB (drop-down ⋯)
|
||||||
|
════════════════════════════════ */
|
||||||
|
.menu-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-panel:not([hidden]) {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 190px;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1.5px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 5px;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
animation: menuFadeIn .14s ease;
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes menuFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-4px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
transition: background .12s ease, color .12s ease;
|
||||||
|
}
|
||||||
|
.menu-item:hover {
|
||||||
|
background: rgba(167, 139, 250, 0.12);
|
||||||
|
color: var(--violet-deep);
|
||||||
|
}
|
||||||
|
.menu-item-danger { color: var(--danger); }
|
||||||
|
.menu-item-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.10);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header {
|
||||||
|
padding: 7px 10px 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breadcrumb dentro del modal de subcategoría */
|
||||||
|
.subcat-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(167, 139, 250, 0.08);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.subcat-breadcrumb .bc-cat {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.subcat-breadcrumb .bc-arrow { color: var(--ink-mute); }
|
||||||
|
.subcat-breadcrumb .bc-new {
|
||||||
|
color: var(--violet);
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload: grupos de subcategoría + indicador de destino */
|
||||||
|
.subcat-group { animation: subcatFadeIn .2s ease; }
|
||||||
|
@keyframes subcatFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.upload-destino {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(124, 92, 252, 0.08);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.upload-destino-label {
|
||||||
|
color: var(--ink-mute);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.upload-destino-path {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.you-badge {
|
.you-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
@@ -1294,6 +1561,17 @@ select {
|
|||||||
.subtab { padding: 8px 12px; font-size: 0.82rem; }
|
.subtab { padding: 8px 12px; font-size: 0.82rem; }
|
||||||
.subtab-placeholder { padding: 32px 16px; }
|
.subtab-placeholder { padding: 32px 16px; }
|
||||||
.subtab-placeholder-icon { font-size: 2.6rem; }
|
.subtab-placeholder-icon { font-size: 2.6rem; }
|
||||||
|
.categoria-header {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
.categoria-header .categoria-count { grid-column: 1; }
|
||||||
|
.categoria-header .categoria-actions { grid-column: 1 / -1; }
|
||||||
|
.subcat-row { padding-left: 32px; }
|
||||||
|
.subcat-row::before { left: 18px; }
|
||||||
|
.subcat-children .subcat-row { padding-left: 52px; }
|
||||||
|
.subcat-children .subcat-row::before { left: 38px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════
|
/* ════════════════════════════════
|
||||||
|
|||||||
@@ -766,20 +766,40 @@ function renderCategorias(categorias, productos) {
|
|||||||
grid.innerHTML = categorias.map(c => {
|
grid.innerHTML = categorias.map(c => {
|
||||||
const count = countByCat[c.id] || 0;
|
const count = countByCat[c.id] || 0;
|
||||||
const isSistema = c.id === 'sin-categoria';
|
const isSistema = c.id === 'sin-categoria';
|
||||||
|
const subcats = Array.isArray(c.subcategorias) ? c.subcategorias : [];
|
||||||
|
const tieneSubcats = subcats.length > 0;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="categoria-card ${isSistema ? 'categoria-sistema' : ''}">
|
<div class="categoria-card ${isSistema ? 'categoria-sistema' : ''}">
|
||||||
${isSistema ? '<span class="categoria-sistema-badge">Sistema</span>' : ''}
|
<div class="categoria-header">
|
||||||
<div class="categoria-icon">${c.icono || '📦'}</div>
|
<div class="categoria-icon">${c.icono || '📦'}</div>
|
||||||
|
<div class="categoria-meta">
|
||||||
<div class="categoria-nombre">${escapeHtml(c.nombre)}</div>
|
<div class="categoria-nombre">${escapeHtml(c.nombre)}</div>
|
||||||
<div class="categoria-id">${escapeHtml(c.id)}</div>
|
<div class="categoria-id">${escapeHtml(c.id)}</div>
|
||||||
|
</div>
|
||||||
<div class="categoria-count">${count} item${count === 1 ? '' : 's'}</div>
|
<div class="categoria-count">${count} item${count === 1 ? '' : 's'}</div>
|
||||||
${!isSistema ? `
|
${!isSistema ? `
|
||||||
<div class="categoria-actions">
|
<div class="categoria-actions">
|
||||||
<button class="btn-icon" onclick="abrirEditarCategoria('${escapeAttr(c.id)}')" title="Editar" type="button">✏️</button>
|
<div class="menu-dropdown">
|
||||||
<button class="btn-icon btn-icon-danger" onclick="abrirEliminarCategoria('${escapeAttr(c.id)}','${escapeAttr(c.nombre)}')" title="Eliminar" type="button">🗑️</button>
|
<button class="btn-icon-tiny menu-toggle" type="button" aria-label="Más acciones" aria-haspopup="true" aria-expanded="false">⋯</button>
|
||||||
|
<div class="menu-panel" hidden>
|
||||||
|
<div class="menu-header" title="${escapeAttr(c.nombre)}">${escapeHtml(c.nombre)}</div>
|
||||||
|
<button type="button" class="menu-item" data-action="add-sub-cat" data-id="${escapeAttr(c.id)}">
|
||||||
|
<span class="menu-icon">+</span> Agregar subcategoría
|
||||||
|
</button>
|
||||||
|
<button type="button" class="menu-item" data-action="edit-cat" data-id="${escapeAttr(c.id)}">
|
||||||
|
<span class="menu-icon">✏️</span> Editar nombre e icono
|
||||||
|
</button>
|
||||||
|
<button type="button" class="menu-item menu-item-danger" data-action="delete-cat" data-id="${escapeAttr(c.id)}" data-name="${escapeAttr(c.nombre)}">
|
||||||
|
<span class="menu-icon">🗑️</span> Eliminar categoría
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${tieneSubcats ? `<div class="subcat-tree">${renderSubcatRows(subcats, [c.id])}</div>` : ''}
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -937,6 +957,319 @@ function setupCategoriasModals() {
|
|||||||
document.getElementById('btnCrearCategoria').addEventListener('click', abrirCrearCategoria);
|
document.getElementById('btnCrearCategoria').addEventListener('click', abrirCrearCategoria);
|
||||||
document.getElementById('formCategoria').addEventListener('submit', guardarCategoria);
|
document.getElementById('formCategoria').addEventListener('submit', guardarCategoria);
|
||||||
document.getElementById('btnConfirmarEliminarCategoria').addEventListener('click', confirmarEliminarCategoria);
|
document.getElementById('btnConfirmarEliminarCategoria').addEventListener('click', confirmarEliminarCategoria);
|
||||||
|
document.getElementById('formSubcategoria').addEventListener('submit', guardarSubcategoria);
|
||||||
|
document.getElementById('btnConfirmarEliminarSubcat').addEventListener('click', confirmarEliminarSubcategoria);
|
||||||
|
setupKebabMenus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════
|
||||||
|
// MENÚ KEBAB (drop-down ⋯) — para categoría y subcats
|
||||||
|
// - Click en .menu-toggle → abre/cierra
|
||||||
|
// - Click en .menu-item → ejecuta acción, cierra
|
||||||
|
// - Click fuera / Escape → cierra todos
|
||||||
|
// - Al abrir, marca .menu-active en el contenedor para que no se oculte al perder hover
|
||||||
|
// ════════════════════════════════
|
||||||
|
function setupKebabMenus() {
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
// Click en el toggle ⋯
|
||||||
|
const toggle = e.target.closest('.menu-toggle');
|
||||||
|
if (toggle) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const dropdown = toggle.parentElement;
|
||||||
|
const panel = dropdown.querySelector('.menu-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
const wasOpen = !panel.hidden;
|
||||||
|
closeAllKebabMenus();
|
||||||
|
if (!wasOpen) {
|
||||||
|
panel.hidden = false;
|
||||||
|
toggle.setAttribute('aria-expanded', 'true');
|
||||||
|
// Marcar la card como host para que flote sobre los vecinos
|
||||||
|
const card = dropdown.closest('.categoria-card');
|
||||||
|
if (card) card.classList.add('menu-host');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click en un item del menú
|
||||||
|
const item = e.target.closest('.menu-item');
|
||||||
|
if (item) {
|
||||||
|
const action = item.dataset.action;
|
||||||
|
closeAllKebabMenus();
|
||||||
|
if (action === 'add-sub-cat') abrirCrearSubcategoria(item.dataset.id);
|
||||||
|
else if (action === 'add-sub-sub-cat') abrirCrearSubcatHijo(item.dataset.cat, item.dataset.sub);
|
||||||
|
else if (action === 'edit-subcat') abrirEditarSubcategoria(item.dataset.cat, item.dataset.sub);
|
||||||
|
else if (action === 'delete-subcat') abrirEliminarSubcategoria(item.dataset.cat, item.dataset.sub, item.dataset.name);
|
||||||
|
else if (action === 'edit-cat') abrirEditarCategoria(item.dataset.id);
|
||||||
|
else if (action === 'delete-cat') abrirEliminarCategoria(item.dataset.id, item.dataset.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click fuera de cualquier dropdown
|
||||||
|
if (!e.target.closest('.menu-dropdown')) {
|
||||||
|
closeAllKebabMenus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape cierra todos
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') closeAllKebabMenus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllKebabMenus() {
|
||||||
|
document.querySelectorAll('.menu-panel').forEach(p => p.hidden = true);
|
||||||
|
document.querySelectorAll('.menu-toggle').forEach(b => b.setAttribute('aria-expanded', 'false'));
|
||||||
|
document.querySelectorAll('.categoria-card.menu-host')
|
||||||
|
.forEach(c => c.classList.remove('menu-host'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════
|
||||||
|
// SUBCATEGORÍAS (CRUD sobre el árbol anidado)
|
||||||
|
// Estado: editingSubcat = { parentId, subId } cuando se está editando
|
||||||
|
// deletingSubcat = { parentId, subId, nombre } cuando se va a eliminar
|
||||||
|
// ════════════════════════════════
|
||||||
|
let editingSubcat = null;
|
||||||
|
let deletingSubcat = null;
|
||||||
|
|
||||||
|
// Render recursivo de subcategorías como filas anidadas dentro de la card de la categoría
|
||||||
|
function renderSubcatRows(subcats, parentPath) {
|
||||||
|
if (!Array.isArray(subcats) || subcats.length === 0) return '';
|
||||||
|
// parentPath = [c.id] → render nivel 2 (subs). El + aparece (pueden tener sub-subs).
|
||||||
|
// parentPath = [c.id, s.id] → render nivel 3 (sub-subs). El + NO aparece (límite 3 niveles).
|
||||||
|
const puedenTenerHijos = parentPath.length === 1;
|
||||||
|
return subcats.map(s => {
|
||||||
|
const path = [...parentPath, s.id];
|
||||||
|
const tieneSubSub = Array.isArray(s.subcategorias) && s.subcategorias.length > 0;
|
||||||
|
return `
|
||||||
|
<div class="subcat-row" data-subcat-id="${escapeAttr(s.id)}">
|
||||||
|
<div class="subcat-info">
|
||||||
|
<span class="subcat-name">${escapeHtml(s.nombre)}</span>
|
||||||
|
<span class="subcat-id">${escapeHtml(s.id)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="subcat-actions">
|
||||||
|
<div class="menu-dropdown">
|
||||||
|
<button class="btn-icon-tiny menu-toggle" type="button" aria-label="Más acciones" aria-haspopup="true" aria-expanded="false">⋯</button>
|
||||||
|
<div class="menu-panel" hidden>
|
||||||
|
<div class="menu-header" title="${escapeAttr(s.nombre)}">${escapeHtml(s.nombre)}</div>
|
||||||
|
${puedenTenerHijos ? `<button type="button" class="menu-item" data-action="add-sub-sub-cat" data-cat="${escapeAttr(parentPath[0])}" data-sub="${escapeAttr(s.id)}">
|
||||||
|
<span class="menu-icon">+</span> Agregar sub-subcategoría
|
||||||
|
</button>` : ''}
|
||||||
|
<button type="button" class="menu-item" data-action="edit-subcat" data-cat="${escapeAttr(parentPath[0])}" data-sub="${escapeAttr(s.id)}">
|
||||||
|
<span class="menu-icon">✏️</span> Editar nombre
|
||||||
|
</button>
|
||||||
|
<button type="button" class="menu-item menu-item-danger" data-action="delete-subcat" data-cat="${escapeAttr(parentPath[0])}" data-sub="${escapeAttr(s.id)}" data-name="${escapeAttr(s.nombre)}">
|
||||||
|
<span class="menu-icon">🗑️</span> Eliminar subcategoría
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${tieneSubSub ? `<div class="subcat-children">${renderSubcatRows(s.subcategorias, path)}</div>` : ''}
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abrirCrearSubcategoria(parentId) {
|
||||||
|
editingSubcat = null;
|
||||||
|
document.getElementById('modalSubcategoriaTitulo').textContent = 'Crear subcategoría';
|
||||||
|
document.getElementById('btnGuardarSubcategoria').textContent = 'Crear';
|
||||||
|
document.getElementById('subcatNombre').value = '';
|
||||||
|
document.getElementById('subcatIdHint').textContent = '';
|
||||||
|
document.getElementById('subcategoriaError').classList.remove('show');
|
||||||
|
document.getElementById('subcategoriaError').textContent = '';
|
||||||
|
// Mostrar breadcrumb del padre (solo la categoría)
|
||||||
|
const categorias = await fetch('/api/categorias', { credentials: 'include' }).then(r => r.json());
|
||||||
|
const parent = categorias.find(c => c.id === parentId);
|
||||||
|
if (parent) {
|
||||||
|
document.getElementById('subcatBreadcrumb').innerHTML =
|
||||||
|
`<span class="bc-cat">${parent.icono || '📦'} ${escapeHtml(parent.nombre)}</span><span class="bc-arrow">›</span><span class="bc-new">Nueva subcategoría</span>`;
|
||||||
|
}
|
||||||
|
abrirModal('modalSubcategoria');
|
||||||
|
|
||||||
|
document.getElementById('subcatNombre').oninput = e => {
|
||||||
|
const slug = slugifyLocal(e.target.value);
|
||||||
|
document.getElementById('subcatIdHint').textContent = slug ? `ID: ${slug}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Guardar el parentId en el dataset del form para usarlo al enviar
|
||||||
|
document.getElementById('formSubcategoria').dataset.parentId = parentId;
|
||||||
|
document.getElementById('formSubcategoria').dataset.subId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abrirCrearSubcatHijo(parentCatId, parentSubId) {
|
||||||
|
// Para crear una sub-subcategoría, el "padre" es la subcategoría (no la categoría raíz)
|
||||||
|
editingSubcat = null;
|
||||||
|
document.getElementById('modalSubcategoriaTitulo').textContent = 'Crear sub-subcategoría';
|
||||||
|
document.getElementById('btnGuardarSubcategoria').textContent = 'Crear';
|
||||||
|
document.getElementById('subcatNombre').value = '';
|
||||||
|
document.getElementById('subcatIdHint').textContent = '';
|
||||||
|
document.getElementById('subcategoriaError').classList.remove('show');
|
||||||
|
document.getElementById('subcategoriaError').textContent = '';
|
||||||
|
|
||||||
|
const categorias = await fetch('/api/categorias', { credentials: 'include' }).then(r => r.json());
|
||||||
|
const path = getCategoryPathCliente(categorias, parentSubId);
|
||||||
|
if (path) {
|
||||||
|
const crumbs = path.map(n => `<span class="bc-cat">${n.icono || '📦'} ${escapeHtml(n.nombre)}</span><span class="bc-arrow">›</span>`).join('');
|
||||||
|
document.getElementById('subcatBreadcrumb').innerHTML = crumbs + '<span class="bc-new">Nueva sub-subcategoría</span>';
|
||||||
|
}
|
||||||
|
abrirModal('modalSubcategoria');
|
||||||
|
|
||||||
|
document.getElementById('subcatNombre').oninput = e => {
|
||||||
|
const slug = slugifyLocal(e.target.value);
|
||||||
|
document.getElementById('subcatIdHint').textContent = slug ? `ID: ${slug}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('formSubcategoria').dataset.parentId = parentCatId;
|
||||||
|
document.getElementById('formSubcategoria').dataset.subId = parentSubId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abrirEditarSubcategoria(parentCatId, subId) {
|
||||||
|
const categorias = await fetch('/api/categorias', { credentials: 'include' }).then(r => r.json());
|
||||||
|
const sub = findSubcatCliente(categorias, subId);
|
||||||
|
if (!sub) return;
|
||||||
|
|
||||||
|
editingSubcat = { parentCatId, subId };
|
||||||
|
document.getElementById('modalSubcategoriaTitulo').textContent = 'Editar subcategoría';
|
||||||
|
document.getElementById('btnGuardarSubcategoria').textContent = 'Guardar cambios';
|
||||||
|
document.getElementById('subcatNombre').value = sub.nombre;
|
||||||
|
document.getElementById('subcatIdHint').textContent = `ID: ${sub.id} (no se puede cambiar)`;
|
||||||
|
document.getElementById('subcategoriaError').classList.remove('show');
|
||||||
|
document.getElementById('subcategoriaError').textContent = '';
|
||||||
|
|
||||||
|
const path = getCategoryPathCliente(categorias, subId);
|
||||||
|
if (path) {
|
||||||
|
const crumbs = path.slice(0, -1).map(n => `<span class="bc-cat">${n.icono || '📦'} ${escapeHtml(n.nombre)}</span><span class="bc-arrow">›</span>`).join('');
|
||||||
|
document.getElementById('subcatBreadcrumb').innerHTML = crumbs + `<span class="bc-cat">${escapeHtml(sub.nombre)}</span>`;
|
||||||
|
}
|
||||||
|
abrirModal('modalSubcategoria');
|
||||||
|
|
||||||
|
document.getElementById('formSubcategoria').dataset.parentId = parentCatId;
|
||||||
|
document.getElementById('formSubcategoria').dataset.subId = subId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirEliminarSubcategoria(parentCatId, subId, nombre) {
|
||||||
|
deletingSubcat = { parentCatId, subId, nombre };
|
||||||
|
document.getElementById('eliminarSubcatNombre').textContent = nombre;
|
||||||
|
document.getElementById('eliminarSubcatError').classList.remove('show');
|
||||||
|
document.getElementById('eliminarSubcatError').textContent = '';
|
||||||
|
abrirModal('modalEliminarSubcategoria');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers cliente (espejo de los del backend)
|
||||||
|
function getCategoryPathCliente(categorias, id, trail = []) {
|
||||||
|
for (const c of categorias) {
|
||||||
|
const next = [...trail, c];
|
||||||
|
if (c.id === id) return next;
|
||||||
|
if (Array.isArray(c.subcategorias) && c.subcategorias.length) {
|
||||||
|
const found = getCategoryPathCliente(c.subcategorias, id, next);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function findSubcatCliente(categorias, id) {
|
||||||
|
for (const c of categorias) {
|
||||||
|
if (Array.isArray(c.subcategorias)) {
|
||||||
|
const found = findInSubcatsCliente(c.subcategorias, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function findInSubcatsCliente(subcats, id) {
|
||||||
|
for (const s of subcats) {
|
||||||
|
if (s.id === id) return s;
|
||||||
|
if (Array.isArray(s.subcategorias) && s.subcategorias.length) {
|
||||||
|
const found = findInSubcatsCliente(s.subcategorias, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardarSubcategoria(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const nombre = document.getElementById('subcatNombre').value.trim();
|
||||||
|
const errEl = document.getElementById('subcategoriaError');
|
||||||
|
const btn = document.getElementById('btnGuardarSubcategoria');
|
||||||
|
const form = document.getElementById('formSubcategoria');
|
||||||
|
const parentId = form.dataset.parentId;
|
||||||
|
const subId = form.dataset.subId;
|
||||||
|
|
||||||
|
if (!nombre) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = editingSubcat ? 'Guardando…' : 'Creando…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url, method, body;
|
||||||
|
|
||||||
|
if (editingSubcat) {
|
||||||
|
// EDITAR: PUT /api/categorias/:parentId/subcategorias/:subId
|
||||||
|
url = `/api/categorias/${encodeURIComponent(parentId)}/subcategorias/${encodeURIComponent(subId)}`;
|
||||||
|
method = 'PUT';
|
||||||
|
body = { nombre };
|
||||||
|
} else if (subId) {
|
||||||
|
// CREAR sub-subcat: POST con parentSubId en el body
|
||||||
|
url = `/api/categorias/${encodeURIComponent(parentId)}/subcategorias`;
|
||||||
|
method = 'POST';
|
||||||
|
body = { nombre, parentSubId: subId };
|
||||||
|
} else {
|
||||||
|
// CREAR subcat nivel 1: POST simple
|
||||||
|
url = `/api/categorias/${encodeURIComponent(parentId)}/subcategorias`;
|
||||||
|
method = 'POST';
|
||||||
|
body = { nombre };
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error al guardar');
|
||||||
|
|
||||||
|
editingSubcat = null;
|
||||||
|
cerrarModal('modalSubcategoria');
|
||||||
|
await cargarCategorias();
|
||||||
|
cargarEstadisticas();
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
errEl.classList.add('show');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = editingSubcat ? 'Guardar cambios' : 'Crear';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmarEliminarSubcategoria() {
|
||||||
|
if (!deletingSubcat) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('btnConfirmarEliminarSubcat');
|
||||||
|
const errEl = document.getElementById('eliminarSubcatError');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Eliminando…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/categorias/${encodeURIComponent(deletingSubcat.parentCatId)}/subcategorias/${encodeURIComponent(deletingSubcat.subId)}`,
|
||||||
|
{ method: 'DELETE', credentials: 'include' }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error al eliminar');
|
||||||
|
|
||||||
|
deletingSubcat = null;
|
||||||
|
cerrarModal('modalEliminarSubcategoria');
|
||||||
|
await cargarCategorias();
|
||||||
|
cargarEstadisticas();
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
errEl.classList.add('show');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Sí, eliminar';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════
|
// ════════════════════════════════
|
||||||
@@ -971,6 +1304,9 @@ async function cargarCategoriasEnUpload() {
|
|||||||
const categorias = await res.json();
|
const categorias = await res.json();
|
||||||
if (!Array.isArray(categorias)) throw new Error('Respuesta no es array');
|
if (!Array.isArray(categorias)) throw new Error('Respuesta no es array');
|
||||||
|
|
||||||
|
// Cachear el árbol completo (con subcategorías) en el módulo para la cascada
|
||||||
|
uploadCategoriasTree = categorias;
|
||||||
|
|
||||||
const seleccionPrevia = select.value;
|
const seleccionPrevia = select.value;
|
||||||
select.innerHTML = '<option value="">— Selecciona una categoría —</option>';
|
select.innerHTML = '<option value="">— Selecciona una categoría —</option>';
|
||||||
categorias
|
categorias
|
||||||
@@ -982,22 +1318,138 @@ async function cargarCategoriasEnUpload() {
|
|||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
if (seleccionPrevia) select.value = seleccionPrevia;
|
if (seleccionPrevia) select.value = seleccionPrevia;
|
||||||
|
|
||||||
console.log(`[upload] Categorías cargadas: ${categorias.filter(c => c.id !== SIN_CAT_ID).length}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[upload] Error cargando categorías:', err);
|
console.error('[upload] Error cargando categorías:', err);
|
||||||
// Fallback visible para el usuario
|
|
||||||
const error = document.getElementById('uploadError');
|
const error = document.getElementById('uploadError');
|
||||||
if (error) error.textContent = 'No se pudieron cargar las categorías. Recarga la página.';
|
if (error) error.textContent = 'No se pudieron cargar las categorías. Recarga la página.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Árbol completo cacheado para la cascada del upload
|
||||||
|
let uploadCategoriasTree = [];
|
||||||
|
|
||||||
|
// Se ejecuta cuando el usuario cambia la categoría
|
||||||
|
function onCategoriaChange() {
|
||||||
|
const catId = document.getElementById('uploadCategoria').value;
|
||||||
|
const subGrp = document.getElementById('uploadSubcatGroup');
|
||||||
|
const subSel = document.getElementById('uploadSubcategoria');
|
||||||
|
const subSubGrp = document.getElementById('uploadSubSubcatGroup');
|
||||||
|
const subSubSel = document.getElementById('uploadSubSubcategoria');
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
subSel.innerHTML = '<option value="">— Selecciona una subcategoría —</option>';
|
||||||
|
subSubSel.innerHTML = '<option value="">— Selecciona una sub-subcategoría —</option>';
|
||||||
|
subGrp.style.display = 'none';
|
||||||
|
subSubGrp.style.display = 'none';
|
||||||
|
|
||||||
|
if (!catId) {
|
||||||
|
actualizarBreadcrumbDestino();
|
||||||
|
updateSubmitButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cat = uploadCategoriasTree.find(c => c.id === catId);
|
||||||
|
if (!cat) return;
|
||||||
|
|
||||||
|
if (Array.isArray(cat.subcategorias) && cat.subcategorias.length > 0) {
|
||||||
|
subGrp.style.display = '';
|
||||||
|
cat.subcategorias.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.id;
|
||||||
|
opt.textContent = `${s.icono || '📂'} ${s.nombre}`;
|
||||||
|
subSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
actualizarBreadcrumbDestino();
|
||||||
|
updateSubmitButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se ejecuta cuando el usuario cambia la subcategoría
|
||||||
|
function onSubcategoriaChange() {
|
||||||
|
const catId = document.getElementById('uploadCategoria').value;
|
||||||
|
const subId = document.getElementById('uploadSubcategoria').value;
|
||||||
|
const subSubGrp = document.getElementById('uploadSubSubcatGroup');
|
||||||
|
const subSubSel = document.getElementById('uploadSubSubcategoria');
|
||||||
|
|
||||||
|
subSubSel.innerHTML = '<option value="">— Selecciona una sub-subcategoría —</option>';
|
||||||
|
subSubGrp.style.display = 'none';
|
||||||
|
|
||||||
|
if (!catId || !subId) {
|
||||||
|
actualizarBreadcrumbDestino();
|
||||||
|
updateSubmitButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cat = uploadCategoriasTree.find(c => c.id === catId);
|
||||||
|
if (!cat || !Array.isArray(cat.subcategorias)) return;
|
||||||
|
|
||||||
|
const sub = cat.subcategorias.find(s => s.id === subId);
|
||||||
|
if (!sub) return;
|
||||||
|
|
||||||
|
if (Array.isArray(sub.subcategorias) && sub.subcategorias.length > 0) {
|
||||||
|
subSubGrp.style.display = '';
|
||||||
|
sub.subcategorias.forEach(ss => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = ss.id;
|
||||||
|
opt.textContent = `${ss.icono || '📂'} ${ss.nombre}`;
|
||||||
|
subSubSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
actualizarBreadcrumbDestino();
|
||||||
|
updateSubmitButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Muestra el breadcrumb del destino actual (cat > sub1 > sub2)
|
||||||
|
function actualizarBreadcrumbDestino() {
|
||||||
|
const cont = document.getElementById('uploadDestino');
|
||||||
|
const path = document.getElementById('uploadDestinoPath');
|
||||||
|
if (!cont || !path) return;
|
||||||
|
|
||||||
|
const catId = document.getElementById('uploadCategoria').value;
|
||||||
|
const subId = document.getElementById('uploadSubcategoria').value;
|
||||||
|
const subSubId= document.getElementById('uploadSubSubcategoria').value;
|
||||||
|
|
||||||
|
if (!catId) { cont.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
const cat = uploadCategoriasTree.find(c => c.id === catId);
|
||||||
|
if (!cat) { cont.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
const parts = [`${cat.icono || '📦'} ${cat.nombre}`];
|
||||||
|
if (subId) {
|
||||||
|
const sub = (cat.subcategorias || []).find(s => s.id === subId);
|
||||||
|
if (sub) {
|
||||||
|
parts.push(`${sub.icono || '📂'} ${sub.nombre}`);
|
||||||
|
if (subSubId) {
|
||||||
|
const subSub = (sub.subcategorias || []).find(s => s.id === subSubId);
|
||||||
|
if (subSub) parts.push(`${subSub.icono || '📂'} ${subSub.nombre}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.textContent = parts.join(' › ');
|
||||||
|
cont.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve el array `sub` que se enviará al backend (la cadena hasta la hoja)
|
||||||
|
function getSubPathActual() {
|
||||||
|
const catId = document.getElementById('uploadCategoria').value;
|
||||||
|
const subId = document.getElementById('uploadSubcategoria').value;
|
||||||
|
const subSubId = document.getElementById('uploadSubSubcategoria').value;
|
||||||
|
|
||||||
|
if (!catId) return [];
|
||||||
|
const path = [];
|
||||||
|
if (subId) path.push(subId);
|
||||||
|
if (subSubId) path.push(subSubId);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
function setupProductosUpload() {
|
function setupProductosUpload() {
|
||||||
const dropzone = document.getElementById('dropzone');
|
const dropzone = document.getElementById('dropzone');
|
||||||
const inputFotos = document.getElementById('inputFotos');
|
const inputFotos = document.getElementById('inputFotos');
|
||||||
const btnLimpiar = document.getElementById('btnLimpiarPreview');
|
const btnLimpiar = document.getElementById('btnLimpiarPreview');
|
||||||
const form = document.getElementById('formUploadProductos');
|
const form = document.getElementById('formUploadProductos');
|
||||||
const select = document.getElementById('uploadCategoria');
|
const select = document.getElementById('uploadCategoria');
|
||||||
|
const selectSub = document.getElementById('uploadSubcategoria');
|
||||||
|
const selectSubSub= document.getElementById('uploadSubSubcategoria');
|
||||||
|
|
||||||
if (!dropzone || !inputFotos) return;
|
if (!dropzone || !inputFotos) return;
|
||||||
|
|
||||||
@@ -1034,7 +1486,10 @@ function setupProductosUpload() {
|
|||||||
renderPreview();
|
renderPreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
select.addEventListener('change', updateSubmitButton);
|
// Cascada: categoría → subcategoría → sub-subcategoría
|
||||||
|
select.addEventListener('change', onCategoriaChange);
|
||||||
|
if (selectSub) selectSub.addEventListener('change', onSubcategoriaChange);
|
||||||
|
if (selectSubSub) selectSubSub.addEventListener('change', updateSubmitButton);
|
||||||
|
|
||||||
form.addEventListener('submit', submitUpload);
|
form.addEventListener('submit', submitUpload);
|
||||||
}
|
}
|
||||||
@@ -1126,10 +1581,27 @@ function renderPreview() {
|
|||||||
|
|
||||||
function updateSubmitButton() {
|
function updateSubmitButton() {
|
||||||
const btn = document.getElementById('btnSubirProductos');
|
const btn = document.getElementById('btnSubirProductos');
|
||||||
const select = document.getElementById('uploadCategoria');
|
const catId = document.getElementById('uploadCategoria').value;
|
||||||
const validos = pendingFiles.filter(p => p.isValid).length;
|
const validos = pendingFiles.filter(p => p.isValid).length;
|
||||||
const hayCat = !!select.value;
|
if (!catId || validos === 0) {
|
||||||
btn.disabled = !(hayCat && validos > 0);
|
btn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Validar que la cadena llegue a una hoja
|
||||||
|
const cat = uploadCategoriasTree.find(c => c.id === catId);
|
||||||
|
if (!cat) { btn.disabled = true; return; }
|
||||||
|
const catTieneSubcats = Array.isArray(cat.subcategorias) && cat.subcategorias.length > 0;
|
||||||
|
if (catTieneSubcats) {
|
||||||
|
const subId = document.getElementById('uploadSubcategoria').value;
|
||||||
|
if (!subId) { btn.disabled = true; return; }
|
||||||
|
const sub = cat.subcategorias.find(s => s.id === subId);
|
||||||
|
if (!sub) { btn.disabled = true; return; }
|
||||||
|
if (Array.isArray(sub.subcategorias) && sub.subcategorias.length > 0) {
|
||||||
|
const subSubId = document.getElementById('uploadSubSubcategoria').value;
|
||||||
|
if (!subSubId) { btn.disabled = true; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitUpload(e) {
|
async function submitUpload(e) {
|
||||||
@@ -1158,8 +1630,11 @@ async function submitUpload(e) {
|
|||||||
btn.querySelector('.btn-text').textContent = 'Subiendo…';
|
btn.querySelector('.btn-text').textContent = 'Subiendo…';
|
||||||
btn.querySelector('.btn-spinner').style.display = 'inline-block';
|
btn.querySelector('.btn-spinner').style.display = 'inline-block';
|
||||||
|
|
||||||
|
const subPath = getSubPathActual();
|
||||||
|
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('categoria', select.value);
|
fd.append('categoria', select.value);
|
||||||
|
fd.append('sub', JSON.stringify(subPath));
|
||||||
validos.forEach(item => fd.append('fotos', item.file, item.file.name));
|
validos.forEach(item => fd.append('fotos', item.file, item.file.name));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="admin.css?v=6">
|
<link rel="stylesheet" href="admin.css?v=16">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -133,6 +133,22 @@
|
|||||||
<span class="form-hint">Todas las fotos subidas se guardarán en esta carpeta.</span>
|
<span class="form-hint">Todas las fotos subidas se guardarán en esta carpeta.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group subcat-group" id="uploadSubcatGroup" style="display:none">
|
||||||
|
<label for="uploadSubcategoria">Subcategoría</label>
|
||||||
|
<select id="uploadSubcategoria">
|
||||||
|
<option value="">— Selecciona una subcategoría —</option>
|
||||||
|
</select>
|
||||||
|
<span class="form-hint">Esta categoría tiene subcategorías. Elige la hoja donde van los productos.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group subcat-group" id="uploadSubSubcatGroup" style="display:none">
|
||||||
|
<label for="uploadSubSubcategoria">Sub-subcategoría</label>
|
||||||
|
<select id="uploadSubSubcategoria">
|
||||||
|
<option value="">— Selecciona una sub-subcategoría —</option>
|
||||||
|
</select>
|
||||||
|
<span class="form-hint">Esta subcategoría tiene sub-subcategorías. Elige la hoja final.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Zona de subida</label>
|
<label>Zona de subida</label>
|
||||||
<div class="dropzone" id="dropzone">
|
<div class="dropzone" id="dropzone">
|
||||||
@@ -155,6 +171,11 @@
|
|||||||
<div class="upload-preview-grid" id="previewGrid"></div>
|
<div class="upload-preview-grid" id="previewGrid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-destino" id="uploadDestino" style="display:none">
|
||||||
|
<span class="upload-destino-label">Destino:</span>
|
||||||
|
<span class="upload-destino-path" id="uploadDestinoPath"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="upload-summary" id="uploadSummary" style="display:none"></div>
|
<div class="upload-summary" id="uploadSummary" style="display:none"></div>
|
||||||
|
|
||||||
<div class="form-error" id="uploadError" role="alert"></div>
|
<div class="form-error" id="uploadError" role="alert"></div>
|
||||||
@@ -498,6 +519,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Crear/Editar subcategoría -->
|
||||||
|
<div class="modal" id="modalSubcategoria" style="display:none">
|
||||||
|
<div class="modal-backdrop" data-close-modal="modalSubcategoria"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modalSubcategoriaTitulo">Crear subcategoría</h3>
|
||||||
|
<button class="modal-close" data-close-modal="modalSubcategoria" type="button" aria-label="Cerrar">✕</button>
|
||||||
|
</div>
|
||||||
|
<form id="formSubcategoria" class="modal-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subcatNombre">Nombre</label>
|
||||||
|
<input type="text" id="subcatNombre" required maxlength="40" autocomplete="off">
|
||||||
|
<span class="form-hint" id="subcatIdHint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Padre</label>
|
||||||
|
<div class="subcat-breadcrumb" id="subcatBreadcrumb"></div>
|
||||||
|
<span class="form-hint">La subcategoría heredará el icono de la categoría padre.</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-error" id="subcategoriaError" role="alert"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" data-close-modal="modalSubcategoria">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="btnGuardarSubcategoria">Crear</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Confirmar eliminar subcategoría -->
|
||||||
|
<div class="modal modal-sm" id="modalEliminarSubcategoria" style="display:none">
|
||||||
|
<div class="modal-backdrop" data-close-modal="modalEliminarSubcategoria"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>¿Eliminar subcategoría?</h3>
|
||||||
|
<button class="modal-close" data-close-modal="modalEliminarSubcategoria" type="button" aria-label="Cerrar">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Vas a eliminar la subcategoría <strong id="eliminarSubcatNombre"></strong>.</p>
|
||||||
|
<div class="alert-warning">
|
||||||
|
<span>📁</span>
|
||||||
|
<div>
|
||||||
|
<strong>Los productos asignados a esta subcategoría pasarán a la categoría padre.</strong>
|
||||||
|
<p>(Aún no hay productos con subcategoría — este efecto se aplicará cuando se implemente la carga de productos por subcategoría.)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="modal-warning">Esta acción no se puede deshacer.</p>
|
||||||
|
<div class="form-error" id="eliminarSubcatError" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" data-close-modal="modalEliminarSubcategoria">Cancelar</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="btnConfirmarEliminarSubcat">Sí, eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- MODAL: Confirmar eliminar categoría -->
|
<!-- MODAL: Confirmar eliminar categoría -->
|
||||||
<div class="modal modal-sm" id="modalEliminarCategoria" style="display:none">
|
<div class="modal modal-sm" id="modalEliminarCategoria" style="display:none">
|
||||||
<div class="modal-backdrop" data-close-modal="modalEliminarCategoria"></div>
|
<div class="modal-backdrop" data-close-modal="modalEliminarCategoria"></div>
|
||||||
@@ -545,6 +621,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="admin.js?v=6"></script>
|
<script src="admin.js?v=16"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function escapeHtml(str) {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
let catActiva = 'todos';
|
let catActiva = 'todos';
|
||||||
|
let subActiva = []; // [nivel0, nivel1, ...] — null = "Todos" en ese nivel
|
||||||
let busqueda = '';
|
let busqueda = '';
|
||||||
let cargando = true;
|
let cargando = true;
|
||||||
|
|
||||||
@@ -56,29 +57,151 @@ async function cargarDatos() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════
|
// ════════════════════════════════
|
||||||
// RENDER CATEGORÍAS
|
// HELPERS DE FILTRADO POR SUBRAMA
|
||||||
// ════════════════════════════════
|
// ════════════════════════════════
|
||||||
// RENDER TAGS FILTROS
|
|
||||||
// ════════════════════════════════
|
|
||||||
function renderTags() {
|
|
||||||
const cont = document.getElementById('filtrosTags');
|
|
||||||
// Filtrar la categoría del sistema "sin-categoria" de los tags
|
|
||||||
const visibles = categorias.filter(c => c.id !== 'sin-categoria');
|
|
||||||
cont.innerHTML =
|
|
||||||
`<button class="tag active" data-cat="todos">Todos</button>` +
|
|
||||||
visibles.map(cat => `<button class="tag" data-cat="${cat.id}"><span class="tag-icon">${cat.icono || '📦'}</span> ${cat.nombre}</button>`).join('');
|
|
||||||
|
|
||||||
cont.querySelectorAll('.tag').forEach(tag => {
|
// Devuelve los productos que matchean la rama actual (cat + sub[niveles anteriores])
|
||||||
tag.addEventListener('click', () => {
|
// y cuyo sub[i] === id. Sirve para contar/mostrar subcats con productos.
|
||||||
catActiva = tag.dataset.cat;
|
function productosEnNivel(catId, nivelesPrevios, idNivel) {
|
||||||
cont.querySelectorAll('.tag').forEach(t => t.classList.remove('active'));
|
return productos.filter(p => {
|
||||||
tag.classList.add('active');
|
if (!p || p.cat !== catId) return false;
|
||||||
renderProductos();
|
if (p.img && p.img.startsWith('agotados/')) return false;
|
||||||
});
|
// El sub del producto debe tener al menos la longitud de nivelesPrevios + 1
|
||||||
|
const sub = Array.isArray(p.sub) ? p.sub : [];
|
||||||
|
if (sub.length < nivelesPrevios.length + 1) return false;
|
||||||
|
// Coincidir con los niveles previos
|
||||||
|
for (let i = 0; i < nivelesPrevios.length; i++) {
|
||||||
|
if (sub[i] !== nivelesPrevios[i]) return false;
|
||||||
|
}
|
||||||
|
// El nivel actual debe ser este id
|
||||||
|
return sub[nivelesPrevios.length] === idNivel;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Devuelve el nodo del árbol (categoría o subcat) según el path
|
||||||
|
function getNodo(catId, niveles) {
|
||||||
|
const cat = categorias.find(c => c.id === catId);
|
||||||
|
if (!cat) return null;
|
||||||
|
let nodo = cat;
|
||||||
|
for (const id of niveles) {
|
||||||
|
if (!Array.isArray(nodo.subcategorias)) return null;
|
||||||
|
const sig = nodo.subcategorias.find(s => s.id === id);
|
||||||
|
if (!sig) return null;
|
||||||
|
nodo = sig;
|
||||||
|
}
|
||||||
|
return nodo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════
|
||||||
|
// RENDER TAGS FILTROS (multinivel)
|
||||||
|
// ════════════════════════════════
|
||||||
|
function renderTags() {
|
||||||
|
const cont = document.getElementById('filtrosTags');
|
||||||
|
cont.innerHTML = '';
|
||||||
|
|
||||||
|
// ——— Nivel 0: Categorías ———
|
||||||
|
const catsVisibles = categorias.filter(c => c.id !== 'sin-categoria');
|
||||||
|
const nivel0 = document.createElement('div');
|
||||||
|
nivel0.className = 'filtros-tags filtros-nivel';
|
||||||
|
nivel0.innerHTML =
|
||||||
|
`<button class="tag ${catActiva === 'todos' ? 'active' : ''}" data-nivel="0" data-id="todos">Todos</button>` +
|
||||||
|
catsVisibles.map(cat => {
|
||||||
|
const tieneSubcats = Array.isArray(cat.subcategorias) && cat.subcategorias.length > 0;
|
||||||
|
// Solo mostrar la categoría como tag si tiene productos directos O tiene subcats con productos
|
||||||
|
if (tieneSubcats) {
|
||||||
|
// No renderizar la categoría como "hoja" — sus subcats la cubren
|
||||||
|
// PERO si alguna subcat es hoja con productos, sí tiene sentido mostrarla igual
|
||||||
|
// porque al hacer click se entra al nivel 1
|
||||||
|
}
|
||||||
|
return `<button class="tag ${catActiva === cat.id ? 'active' : ''}" data-nivel="0" data-id="${cat.id}">
|
||||||
|
<span class="tag-icon">${cat.icono || '📦'}</span> ${cat.nombre}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
cont.appendChild(nivel0);
|
||||||
|
|
||||||
|
// ——— Niveles 1+ : subcategorías (solo si catActiva está elegida) ———
|
||||||
|
if (catActiva !== 'todos') {
|
||||||
|
const cat = categorias.find(c => c.id === catActiva);
|
||||||
|
if (cat && Array.isArray(cat.subcategorias) && cat.subcategorias.length > 0) {
|
||||||
|
renderNivelSub(cont, cat, [], 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire click handlers
|
||||||
|
cont.querySelectorAll('.tag').forEach(tag => {
|
||||||
|
tag.addEventListener('click', () => onTagClick(parseInt(tag.dataset.nivel, 10), tag.dataset.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderiza un nivel de subcategorías. `previos` = niveles ya elegidos arriba.
|
||||||
|
function renderNivelSub(cont, nodo, previos, nivel) {
|
||||||
|
if (!Array.isArray(nodo.subcategorias) || nodo.subcategorias.length === 0) return;
|
||||||
|
|
||||||
|
// Subcats que tienen productos en este nivel o cuyas sub-subs tienen productos
|
||||||
|
const subsVisibles = nodo.subcategorias.filter(s => {
|
||||||
|
const tieneSubSub = Array.isArray(s.subcategorias) && s.subcategorias.length > 0;
|
||||||
|
if (tieneSubSub) {
|
||||||
|
// Esta subcat NO recibe productos directamente. Mostrar solo si ALGUNA
|
||||||
|
// de sus sub-subs tiene productos.
|
||||||
|
return s.subcategorias.some(ss =>
|
||||||
|
productosEnNivel(catActiva, [...previos, s.id], ss.id).length > 0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Hoja: recibe productos. Mostrar si tiene al menos uno.
|
||||||
|
return productosEnNivel(catActiva, [...previos], s.id).length > 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subsVisibles.length === 0) return;
|
||||||
|
|
||||||
|
const nivelEl = document.createElement('div');
|
||||||
|
nivelEl.className = 'filtros-tags filtros-nivel';
|
||||||
|
const idTodos = `__all_${nivel}`;
|
||||||
|
nivelEl.innerHTML =
|
||||||
|
`<span class="filtros-nivel-label">${escapeHtml(nodo.nombre || '')}</span>` +
|
||||||
|
`<button class="tag ${subActiva[nivel - 1] == null ? 'active' : ''}" data-nivel="${nivel}" data-id="${idTodos}">Todos</button>` +
|
||||||
|
subsVisibles.map(s => {
|
||||||
|
const esActivo = subActiva[nivel - 1] === s.id;
|
||||||
|
return `<button class="tag ${esActivo ? 'active' : ''}" data-nivel="${nivel}" data-id="${s.id}">
|
||||||
|
<span class="tag-icon">${s.icono || '📂'}</span> ${escapeHtml(s.nombre)}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
cont.appendChild(nivelEl);
|
||||||
|
|
||||||
|
// Si la subcat activa tiene sub-subs, renderizar el siguiente nivel
|
||||||
|
if (subActiva[nivel - 1]) {
|
||||||
|
const subActivaNodo = nodo.subcategorias.find(s => s.id === subActiva[nivel - 1]);
|
||||||
|
if (subActivaNodo && Array.isArray(subActivaNodo.subcategorias) && subActivaNodo.subcategorias.length > 0) {
|
||||||
|
renderNivelSub(cont, subActivaNodo, [...previos, subActiva[nivel - 1]], nivel + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTagClick(nivel, id) {
|
||||||
|
if (nivel === 0) {
|
||||||
|
if (id === 'todos') {
|
||||||
|
catActiva = 'todos';
|
||||||
|
} else {
|
||||||
|
catActiva = id;
|
||||||
|
}
|
||||||
|
subActiva = []; // reset sub al cambiar categoría
|
||||||
|
} else {
|
||||||
|
if (id.startsWith('__all_')) {
|
||||||
|
subActiva[nivel - 1] = null;
|
||||||
|
} else {
|
||||||
|
subActiva[nivel - 1] = id;
|
||||||
|
}
|
||||||
|
// Resetear niveles más profundos
|
||||||
|
subActiva = subActiva.slice(0, nivel);
|
||||||
|
// Rellenar con null hasta el nivel actual
|
||||||
|
while (subActiva.length < nivel) subActiva.push(null);
|
||||||
|
}
|
||||||
|
renderTags();
|
||||||
|
renderProductos();
|
||||||
|
}
|
||||||
|
|
||||||
function sincronizarTag(cat) {
|
function sincronizarTag(cat) {
|
||||||
|
// Legacy — mantenido por compatibilidad, pero ya no se usa
|
||||||
document.querySelectorAll('.tag').forEach(t => {
|
document.querySelectorAll('.tag').forEach(t => {
|
||||||
t.classList.toggle('active', t.dataset.cat === cat);
|
t.classList.toggle('active', t.dataset.cat === cat);
|
||||||
});
|
});
|
||||||
@@ -92,8 +215,6 @@ function renderProductos() {
|
|||||||
const empty = document.getElementById('emptyState');
|
const empty = document.getElementById('emptyState');
|
||||||
const contador = document.getElementById('contador');
|
const contador = document.getElementById('contador');
|
||||||
|
|
||||||
// Filtrar productos sin categoría (sin-categoria) y productos AGOTADOS
|
|
||||||
// (imagen movida a la carpeta "agotados/") — no se muestran en el catálogo público.
|
|
||||||
let filtrados = productos.filter(p =>
|
let filtrados = productos.filter(p =>
|
||||||
p.cat &&
|
p.cat &&
|
||||||
p.cat !== 'sin-categoria' &&
|
p.cat !== 'sin-categoria' &&
|
||||||
@@ -101,7 +222,15 @@ function renderProductos() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (catActiva !== 'todos') {
|
if (catActiva !== 'todos') {
|
||||||
filtrados = filtrados.filter(p => p.cat === catActiva);
|
filtrados = filtrados.filter(p => {
|
||||||
|
if (p.cat !== catActiva) return false;
|
||||||
|
// Validar que la cadena de sub coincida
|
||||||
|
const sub = Array.isArray(p.sub) ? p.sub : [];
|
||||||
|
for (let i = 0; i < subActiva.length; i++) {
|
||||||
|
if (subActiva[i] != null && sub[i] !== subActiva[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (busqueda.trim()) {
|
if (busqueda.trim()) {
|
||||||
@@ -128,13 +257,11 @@ function renderProductos() {
|
|||||||
|
|
||||||
grid.innerHTML = filtrados.map((p, i) => {
|
grid.innerHTML = filtrados.map((p, i) => {
|
||||||
const cat = categorias.find(c => c.id === p.cat);
|
const cat = categorias.find(c => c.id === p.cat);
|
||||||
// Fallbacks: si no hay nombre, usar la referencia; si no hay descripción, ocultar
|
|
||||||
const nombre = p.nombre || p.ref || 'Sin nombre';
|
const nombre = p.nombre || p.ref || 'Sin nombre';
|
||||||
const desc = p.desc || '';
|
const desc = p.desc || '';
|
||||||
const precio = (p.precio !== undefined && p.precio !== null && p.precio !== '')
|
const precio = (p.precio !== undefined && p.precio !== null && p.precio !== '')
|
||||||
? `$${Number(p.precio).toLocaleString('es-CO')}`
|
? `$${Number(p.precio).toLocaleString('es-CO')}`
|
||||||
: '—';
|
: '—';
|
||||||
// La ruta en el JSON es relativa a /Imagenes/ (ej: "bolsos/AF100.webp")
|
|
||||||
const imgSrc = p.img && p.img.startsWith('/') ? p.img : `/Imagenes/${p.img}`;
|
const imgSrc = p.img && p.img.startsWith('/') ? p.img : `/Imagenes/${p.img}`;
|
||||||
return `
|
return `
|
||||||
<article class="producto-card" style="animation-delay: ${i * 0.04}s">
|
<article class="producto-card" style="animation-delay: ${i * 0.04}s">
|
||||||
|
|||||||
@@ -484,6 +484,28 @@ section {
|
|||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Multinivel: cada nivel es una fila independiente */
|
||||||
|
.filtros-nivel {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
align-items: center;
|
||||||
|
animation: filtrosFadeIn .25s ease;
|
||||||
|
}
|
||||||
|
.filtros-nivel:last-child { margin-bottom: 32px; }
|
||||||
|
.filtros-nivel .filtros-nivel-label {
|
||||||
|
color: var(--ink-mute);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-right: 4px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
@keyframes filtrosFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
padding: 8px 18px;
|
padding: 8px 18px;
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="estilos.css?v=4">
|
<link rel="stylesheet" href="estilos.css?v=5">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -102,6 +102,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="app.js?v=4"></script>
|
<script src="app.js?v=5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||