pestana descargas y subcategorias funcionando

This commit is contained in:
2026-06-06 01:15:43 -05:00
parent 21889a3761
commit 3439e598b3
38 changed files with 1609 additions and 95 deletions
+276 -6
View File
@@ -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: <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 });
// 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) {