diff --git a/Imagenes/agotados/AF100-124.webp b/Imagenes/agotados/AF100-124.webp deleted file mode 100644 index 254c2cc..0000000 Binary files a/Imagenes/agotados/AF100-124.webp and /dev/null differ diff --git a/Imagenes/bolsos/AF100-128.webp b/Imagenes/bolsos/AF100-128.webp deleted file mode 100644 index 88c0a54..0000000 Binary files a/Imagenes/bolsos/AF100-128.webp and /dev/null differ diff --git a/Imagenes/bolsos/AF100-129.webp b/Imagenes/bolsos/AF100-129.webp deleted file mode 100644 index 175384f..0000000 Binary files a/Imagenes/bolsos/AF100-129.webp and /dev/null differ diff --git a/Imagenes/bolsos/AF100-159.webp b/Imagenes/bolsos/AF100-159.webp deleted file mode 100644 index 3aac912..0000000 Binary files a/Imagenes/bolsos/AF100-159.webp and /dev/null differ diff --git a/Imagenes/bolsos/AF100-171.webp b/Imagenes/bolsos/AF100-171.webp deleted file mode 100644 index fe543c9..0000000 Binary files a/Imagenes/bolsos/AF100-171.webp and /dev/null differ diff --git a/Imagenes/bolsos/AF100-174.webp b/Imagenes/bolsos/AF100-174.webp deleted file mode 100644 index 0300901..0000000 Binary files a/Imagenes/bolsos/AF100-174.webp and /dev/null differ diff --git a/Imagenes/bolsos/AF9173.webp b/Imagenes/bolsos/AF9173.webp deleted file mode 100644 index 58c34ca..0000000 Binary files a/Imagenes/bolsos/AF9173.webp and /dev/null differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-197.webp b/Imagenes/bolsos/dama/aaa/AF100-197.webp new file mode 100644 index 0000000..10c9cde Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-197.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-198.webp b/Imagenes/bolsos/dama/aaa/AF100-198.webp new file mode 100644 index 0000000..4a04138 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-198.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-199.webp b/Imagenes/bolsos/dama/aaa/AF100-199.webp new file mode 100644 index 0000000..d9a1386 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-199.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-201.webp b/Imagenes/bolsos/dama/aaa/AF100-201.webp new file mode 100644 index 0000000..304da13 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-201.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-202.webp b/Imagenes/bolsos/dama/aaa/AF100-202.webp new file mode 100644 index 0000000..94e859b Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-202.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-208.webp b/Imagenes/bolsos/dama/aaa/AF100-208.webp new file mode 100644 index 0000000..1f04793 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-208.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-209.webp b/Imagenes/bolsos/dama/aaa/AF100-209.webp new file mode 100644 index 0000000..0b5a4f3 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-209.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-210.webp b/Imagenes/bolsos/dama/aaa/AF100-210.webp new file mode 100644 index 0000000..99b216d Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-210.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-216.webp b/Imagenes/bolsos/dama/aaa/AF100-216.webp new file mode 100644 index 0000000..874e0d6 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-216.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-219.webp b/Imagenes/bolsos/dama/aaa/AF100-219.webp new file mode 100644 index 0000000..4f74ef6 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-219.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-223.webp b/Imagenes/bolsos/dama/aaa/AF100-223.webp new file mode 100644 index 0000000..e2618f5 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-223.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF100-224.webp b/Imagenes/bolsos/dama/aaa/AF100-224.webp new file mode 100644 index 0000000..d60b5ac Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF100-224.webp differ diff --git a/Imagenes/bolsos/dama/aaa/AF9170.webp b/Imagenes/bolsos/dama/aaa/AF9170.webp new file mode 100644 index 0000000..17c4ced Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/AF9170.webp differ diff --git a/Imagenes/bolsos/dama/aaa/MG-067.webp b/Imagenes/bolsos/dama/aaa/MG-067.webp new file mode 100644 index 0000000..7a82b40 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/MG-067.webp differ diff --git a/Imagenes/bolsos/dama/aaa/TEST-A.webp b/Imagenes/bolsos/dama/aaa/TEST-A.webp new file mode 100644 index 0000000..423e709 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/TEST-A.webp differ diff --git a/Imagenes/bolsos/dama/aaa/TEST-B.webp b/Imagenes/bolsos/dama/aaa/TEST-B.webp new file mode 100644 index 0000000..423e709 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/TEST-B.webp differ diff --git a/Imagenes/bolsos/dama/aaa/TEST-C.webp b/Imagenes/bolsos/dama/aaa/TEST-C.webp new file mode 100644 index 0000000..423e709 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/TEST-C.webp differ diff --git a/Imagenes/bolsos/dama/aaa/TEST-D.webp b/Imagenes/bolsos/dama/aaa/TEST-D.webp new file mode 100644 index 0000000..423e709 Binary files /dev/null and b/Imagenes/bolsos/dama/aaa/TEST-D.webp differ diff --git a/Imagenes/bolsos/dama/bolso-viajero/AF2296.webp b/Imagenes/bolsos/dama/bolso-viajero/AF2296.webp new file mode 100644 index 0000000..cbda2e4 Binary files /dev/null and b/Imagenes/bolsos/dama/bolso-viajero/AF2296.webp differ diff --git a/Imagenes/bolsos/dama/bolso-viajero/AF9013.webp b/Imagenes/bolsos/dama/bolso-viajero/AF9013.webp new file mode 100644 index 0000000..78475a7 Binary files /dev/null and b/Imagenes/bolsos/dama/bolso-viajero/AF9013.webp differ diff --git a/Imagenes/bolsos/dama/bolso-viajero/AF9391.webp b/Imagenes/bolsos/dama/bolso-viajero/AF9391.webp new file mode 100644 index 0000000..99ab4b3 Binary files /dev/null and b/Imagenes/bolsos/dama/bolso-viajero/AF9391.webp differ diff --git a/backend/server.js b/backend/server.js index 224d918..eb06f15 100644 --- a/backend/server.js +++ b/backend/server.js @@ -126,6 +126,102 @@ async function saveCategorias(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() { const categorias = await getCategorias(); 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') // ════════════════════════════════ @@ -718,6 +939,7 @@ function parseFilename(name) { app.post('/api/productos/upload', requireProductosManagement, upload.array('fotos', MAX_FILES), async (req, res) => { const categoria = (req.body.categoria || '').trim(); + const subRaw = req.body.sub || '[]'; const files = req.files || []; 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' }); } + // 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 { // Verificar que la categoría existe y NO es sin-categoria 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' }); } - // Crear carpeta destino si no existe - const dstDir = path.join(IMG_DIR, categoria); + // Validar la cadena de subcats contra el árbol: + // - 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: o / o // + const pathParts = [categoria, ...subPath]; + const relDir = pathParts.join('/'); + const dstDir = path.join(IMG_DIR, relDir); await fs.mkdir(dstDir, { recursive: true }); // Cargar productos existentes @@ -782,10 +1051,11 @@ app.post('/api/productos/upload', requireProductosManagement, upload.array('foto .toFile(outPath); productos.push({ - id: ref, - ref: ref, - cat: categoria, - img: `${categoria}/${outName}` + id: ref, + ref: ref, + cat: categoria, + sub: subPath, + img: `${relDir}/${outName}` }); creados.push({ ref }); } catch (err) { diff --git a/data/categorias.json b/data/categorias.json index 831c42e..2bc9f3c 100644 --- a/data/categorias.json +++ b/data/categorias.json @@ -7,6 +7,22 @@ { "id": "bolsos", "nombre": "BOLSOS", - "icono": "👜" + "icono": "👜", + "subcategorias": [ + { + "id": "dama", + "nombre": "DAMA", + "subcategorias": [ + { + "id": "aaa", + "nombre": "BILLETERAS" + }, + { + "id": "bolso-viajero", + "nombre": "BOLSO VIAJERO" + } + ] + } + ] } ] diff --git a/data/productos.json b/data/productos.json index ea2812c..64ac9f7 100644 --- a/data/productos.json +++ b/data/productos.json @@ -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", "ref": "AF100-128", - "cat": "bolsos", - "img": "bolsos/AF100-128.webp", + "cat": "sin-categoria", + "img": "sin-categoria/AF100-128.webp", "nombre": "Bolso de mano elegante con detalles dorados", - "precio": 75000 + "precio": 75000, + "sub": [] }, { "id": "AF100-129", "ref": "AF100-129", - "cat": "bolsos", - "img": "bolsos/AF100-129.webp", + "cat": "sin-categoria", + "img": "sin-categoria/AF100-129.webp", "nombre": "Bolso deportivo resistente al agua", - "precio": 45000.5 + "precio": 45000.5, + "sub": [] }, { "id": "AF100-159", "ref": "AF100-159", - "cat": "bolsos", - "img": "bolsos/AF100-159.webp", - "precio": 85000 + "cat": "sin-categoria", + "img": "sin-categoria/AF100-159.webp", + "precio": 85000, + "sub": [] }, { "id": "AF100-171", "ref": "AF100-171", - "cat": "bolsos", - "img": "bolsos/AF100-171.webp", - "nombre": "Mochila urbana minimalista" + "cat": "sin-categoria", + "img": "sin-categoria/AF100-171.webp", + "nombre": "Mochila urbana minimalista", + "sub": [] }, { "id": "AF100-174", "ref": "AF100-174", - "cat": "bolsos", - "img": "bolsos/AF100-174.webp", + "cat": "sin-categoria", + "img": "sin-categoria/AF100-174.webp", "nombre": "Set de bolsos combinados (3 piezas)", - "precio": 120000 + "precio": 120000, + "sub": [] }, { "id": "AF9173", "ref": "AF9173", + "cat": "sin-categoria", + "img": "sin-categoria/AF9173.webp", + "sub": [] + }, + { + "id": "AF100-197", + "ref": "AF100-197", "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" } ] diff --git a/data/sesiones.json b/data/sesiones.json index 9884b65..2ff03c7 100644 --- a/data/sesiones.json +++ b/data/sesiones.json @@ -1,32 +1,74 @@ [ { - "token": "cf5d634b0f9ad09bfcc6370f57c45d2049b7336af476107cea3b5c1617df29e0", + "token": "2ac8079c7722e532838b6585cce4d4e2e1bc50e7dd6fc1ff822a526f1bc06464", "usuario": "admin", "rol": "admin", - "createdAt": 1780718070269 + "createdAt": 1780723402897 }, { - "token": "18342f8390074dea906ffebf0f0a5e34feb6a27c9caf2c7c721542dd961d5d59", + "token": "730b165ea6232fd9fa4aea5bad281efb3ee67cc8eda392c20bfa754ad389840c", "usuario": "admin", "rol": "admin", - "createdAt": 1780719045054 + "createdAt": 1780723462669 }, { - "token": "7e95e1adfdc6f90a52122d72e6e05d24bcafd4c9727be52976ea312a636ab2f5", - "usuario": "vendetest", - "rol": "vendedor", - "createdAt": 1780719045099 - }, - { - "token": "a9b4be06e4e40fe83595d388a8c08b9270df7557e2a2b63b793552609b2d2d71", - "usuario": "vendetest", - "rol": "vendedor", - "createdAt": 1780719061095 - }, - { - "token": "4cb3d9aa79e1c83e87863a32d4cbbc9a2eafadaa08ba110964250121939b8d3a", + "token": "4c713be1e6cd7abe8f87511eb029ed44da6489869f5505fa1762e7650fbd3012", "usuario": "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 } ] diff --git a/frontend/administrativo/admin.css b/frontend/administrativo/admin.css index 0efd232..e01be93 100644 --- a/frontend/administrativo/admin.css +++ b/frontend/administrativo/admin.css @@ -688,12 +688,12 @@ select { } /* ════════════════════════════════ - CATEGORÍAS + ESTILOS DE CATEGORÍAS (vista admin) ════════════════════════════════ */ .categorias-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 18px; } .categoria-card { @@ -1030,6 +1030,273 @@ select { 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 { display: inline-block; margin-left: 6px; @@ -1294,6 +1561,17 @@ select { .subtab { padding: 8px 12px; font-size: 0.82rem; } .subtab-placeholder { padding: 32px 16px; } .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; } } /* ════════════════════════════════ diff --git a/frontend/administrativo/admin.js b/frontend/administrativo/admin.js index a2e81d3..bef0d1f 100644 --- a/frontend/administrativo/admin.js +++ b/frontend/administrativo/admin.js @@ -766,19 +766,39 @@ function renderCategorias(categorias, productos) { grid.innerHTML = categorias.map(c => { const count = countByCat[c.id] || 0; const isSistema = c.id === 'sin-categoria'; + const subcats = Array.isArray(c.subcategorias) ? c.subcategorias : []; + const tieneSubcats = subcats.length > 0; + return `
- ${isSistema ? 'Sistema' : ''} -
${c.icono || '📦'}
-
${escapeHtml(c.nombre)}
-
${escapeHtml(c.id)}
-
${count} item${count === 1 ? '' : 's'}
- ${!isSistema ? ` -
- - +
+
${c.icono || '📦'}
+
+
${escapeHtml(c.nombre)}
+
${escapeHtml(c.id)}
- ` : ''} +
${count} item${count === 1 ? '' : 's'}
+ ${!isSistema ? ` +
+ +
+ ` : ''} +
+ ${tieneSubcats ? `
${renderSubcatRows(subcats, [c.id])}
` : ''}
`; }).join(''); @@ -937,6 +957,319 @@ function setupCategoriasModals() { document.getElementById('btnCrearCategoria').addEventListener('click', abrirCrearCategoria); document.getElementById('formCategoria').addEventListener('submit', guardarCategoria); 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 ` +
+
+ ${escapeHtml(s.nombre)} + ${escapeHtml(s.id)} +
+
+ +
+
+ ${tieneSubSub ? `
${renderSubcatRows(s.subcategorias, path)}
` : ''} + `; + }).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 = + `${parent.icono || '📦'} ${escapeHtml(parent.nombre)}Nueva subcategoría`; + } + 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 => `${n.icono || '📦'} ${escapeHtml(n.nombre)}`).join(''); + document.getElementById('subcatBreadcrumb').innerHTML = crumbs + 'Nueva sub-subcategoría'; + } + 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 => `${n.icono || '📦'} ${escapeHtml(n.nombre)}`).join(''); + document.getElementById('subcatBreadcrumb').innerHTML = crumbs + `${escapeHtml(sub.nombre)}`; + } + 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(); 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; select.innerHTML = ''; categorias @@ -982,22 +1318,138 @@ async function cargarCategoriasEnUpload() { select.appendChild(opt); }); if (seleccionPrevia) select.value = seleccionPrevia; - - console.log(`[upload] Categorías cargadas: ${categorias.filter(c => c.id !== SIN_CAT_ID).length}`); } catch (err) { console.error('[upload] Error cargando categorías:', err); - // Fallback visible para el usuario const error = document.getElementById('uploadError'); 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 = ''; + subSubSel.innerHTML = ''; + 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 = ''; + 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() { const dropzone = document.getElementById('dropzone'); const inputFotos = document.getElementById('inputFotos'); const btnLimpiar = document.getElementById('btnLimpiarPreview'); const form = document.getElementById('formUploadProductos'); const select = document.getElementById('uploadCategoria'); + const selectSub = document.getElementById('uploadSubcategoria'); + const selectSubSub= document.getElementById('uploadSubSubcategoria'); if (!dropzone || !inputFotos) return; @@ -1034,7 +1486,10 @@ function setupProductosUpload() { 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); } @@ -1126,10 +1581,27 @@ function renderPreview() { function updateSubmitButton() { 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 hayCat = !!select.value; - btn.disabled = !(hayCat && validos > 0); + if (!catId || 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) { @@ -1158,8 +1630,11 @@ async function submitUpload(e) { btn.querySelector('.btn-text').textContent = 'Subiendo…'; btn.querySelector('.btn-spinner').style.display = 'inline-block'; + const subPath = getSubPathActual(); + const fd = new FormData(); fd.append('categoria', select.value); + fd.append('sub', JSON.stringify(subPath)); validos.forEach(item => fd.append('fotos', item.file, item.file.name)); try { diff --git a/frontend/administrativo/index.html b/frontend/administrativo/index.html index debacff..31638bd 100644 --- a/frontend/administrativo/index.html +++ b/frontend/administrativo/index.html @@ -7,7 +7,7 @@ - + @@ -133,6 +133,22 @@ Todas las fotos subidas se guardarán en esta carpeta.
+ + + +
@@ -155,6 +171,11 @@
+ + @@ -498,6 +519,61 @@
+ + + + + + - + diff --git a/frontend/app.js b/frontend/app.js index 47ea1e9..cfd9baf 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -18,6 +18,7 @@ function escapeHtml(str) { .replace(/'/g, '''); } let catActiva = 'todos'; +let subActiva = []; // [nivel0, nivel1, ...] — null = "Todos" en ese nivel let busqueda = ''; 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 = - `` + - visibles.map(cat => ``).join(''); - cont.querySelectorAll('.tag').forEach(tag => { - tag.addEventListener('click', () => { - catActiva = tag.dataset.cat; - cont.querySelectorAll('.tag').forEach(t => t.classList.remove('active')); - tag.classList.add('active'); - renderProductos(); - }); +// Devuelve los productos que matchean la rama actual (cat + sub[niveles anteriores]) +// y cuyo sub[i] === id. Sirve para contar/mostrar subcats con productos. +function productosEnNivel(catId, nivelesPrevios, idNivel) { + return productos.filter(p => { + if (!p || p.cat !== catId) return false; + 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 = + `` + + 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 ``; + }).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 = + `${escapeHtml(nodo.nombre || '')}` + + `` + + subsVisibles.map(s => { + const esActivo = subActiva[nivel - 1] === s.id; + return ``; + }).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) { + // Legacy — mantenido por compatibilidad, pero ya no se usa document.querySelectorAll('.tag').forEach(t => { t.classList.toggle('active', t.dataset.cat === cat); }); @@ -92,8 +215,6 @@ function renderProductos() { const empty = document.getElementById('emptyState'); 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 => p.cat && p.cat !== 'sin-categoria' && @@ -101,7 +222,15 @@ function renderProductos() { ); 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()) { @@ -128,13 +257,11 @@ function renderProductos() { grid.innerHTML = filtrados.map((p, i) => { 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 desc = p.desc || ''; const precio = (p.precio !== undefined && p.precio !== null && p.precio !== '') ? `$${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}`; return `
diff --git a/frontend/estilos.css b/frontend/estilos.css index 068c319..83174b2 100644 --- a/frontend/estilos.css +++ b/frontend/estilos.css @@ -484,6 +484,28 @@ section { 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 { padding: 8px 18px; background: var(--white); diff --git a/frontend/index.html b/frontend/index.html index f6316bc..bb3a3b1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@ - + @@ -102,6 +102,6 @@ - +