descargar pdf y fotos

This commit is contained in:
2026-06-09 08:25:57 -05:00
parent 0a9c672c75
commit b19deda02c
4404 changed files with 664668 additions and 84 deletions
+236
View File
@@ -2286,3 +2286,239 @@ select {
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
/* ════════════════════════════════
PDF — árbol de selección + generar
════════════════════════════════ */
.pdf-intro {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 18px;
background: rgba(167, 139, 250, 0.06);
border: 1.5px solid var(--line);
border-radius: var(--radius-md);
margin-bottom: 22px;
}
.pdf-intro-icon {
font-size: 2.2rem;
line-height: 1;
flex-shrink: 0;
filter: drop-shadow(0 4px 12px rgba(167, 139, 250, 0.3));
}
.pdf-intro-title {
font-size: 1.1rem;
font-weight: 800;
color: var(--ink);
margin-bottom: 4px;
letter-spacing: -0.01em;
}
.pdf-intro-desc {
font-size: 0.9rem;
color: var(--ink-soft);
line-height: 1.5;
margin: 0;
}
/* Lista de selección (árbol) */
.pdf-selector {
border: 1.5px solid var(--line);
border-radius: var(--radius-md);
background: var(--white);
padding: 8px;
max-height: 480px;
overflow-y: auto;
margin-bottom: 16px;
}
.pdf-selector-loading {
text-align: center;
color: var(--ink-mute);
padding: 30px 20px;
font-size: 0.92rem;
}
.pdf-selector-empty {
text-align: center;
color: var(--ink-mute);
padding: 30px 20px;
font-size: 0.92rem;
}
/* Cada nodo del árbol */
.pdf-node {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: var(--radius-sm);
transition: background 0.15s ease;
user-select: none;
}
.pdf-node:hover {
background: var(--bg-soft);
}
.pdf-node-children {
margin-left: 22px;
border-left: 1.5px dashed var(--line);
padding-left: 4px;
}
.pdf-node-label {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.92rem;
color: var(--ink);
font-weight: 500;
min-width: 0;
}
.pdf-node-icon {
font-size: 1.05rem;
line-height: 1;
flex-shrink: 0;
}
.pdf-node-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pdf-node-count {
font-size: 0.72rem;
color: var(--ink-mute);
background: var(--bg-soft);
border: 1px solid var(--line);
padding: 1px 8px;
border-radius: var(--radius-pill);
font-weight: 600;
flex-shrink: 0;
}
.pdf-node-count.is-zero {
opacity: 0.45;
}
/* Checkbox custom */
.pdf-check {
position: relative;
display: inline-flex;
width: 20px;
height: 20px;
flex-shrink: 0;
cursor: pointer;
}
.pdf-check input {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 100%;
margin: 0;
border: 2px solid var(--line-strong);
border-radius: 6px;
background: var(--white);
cursor: pointer;
position: relative;
transition: background 0.15s ease, border-color 0.15s ease;
flex-shrink: 0;
}
.pdf-check input:hover {
border-color: var(--violet);
}
.pdf-check input:checked {
background: var(--grad-primary);
border-color: var(--violet-deep);
}
.pdf-check input:checked::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 6px;
height: 11px;
border: solid white;
border-width: 0 2.5px 2.5px 0;
transform: rotate(45deg);
}
.pdf-check input:indeterminate {
background: var(--violet);
border-color: var(--violet-deep);
}
.pdf-check input:indeterminate::after {
content: '';
position: absolute;
left: 3px;
top: 7px;
width: 10px;
height: 2.5px;
background: white;
border-radius: 1px;
}
.pdf-check input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Resumen de selección */
.pdf-summary {
padding: 12px 16px;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: var(--radius-md);
font-size: 0.9rem;
color: var(--ink-soft);
margin-bottom: 16px;
text-align: center;
}
.pdf-summary-text strong {
color: var(--violet-deep);
font-weight: 700;
}
/* Acciones del PDF */
.pdf-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.pdf-actions .btn-ghost {
font-size: 0.88rem;
padding: 10px 18px;
}
.btn-pdf-generar {
min-width: 200px;
justify-content: center;
}
@media (max-width: 600px) {
.pdf-actions {
justify-content: stretch;
}
.pdf-actions .btn {
flex: 1;
min-width: 0;
}
.btn-pdf-generar {
min-width: 0;
}
}
+383 -3
View File
@@ -259,11 +259,11 @@ function setupSubtabs() {
// ════════════════════════════════
// DESCARGAS (vista + subtabs pdf / fotos)
// Funcionalidad TBD — placeholders por ahora
// ════════════════════════════════
function cargarDescargas() {
// Placeholder. Cuando se implemente cada subpestaña, se cargan
// los datos del backend (PDFs disponibles, lotes de fotos, etc.).
initGestores();
pdfGestor.setup();
fotosGestor.setup();
}
// ════════════════════════════════
@@ -2162,3 +2162,383 @@ document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('btnConfirmarEliminarProdDef');
if (btn) btn.addEventListener('click', confirmarEliminarProductoDef);
});
// ════════════════════════════════
// DESCARGAS — gestor genérico de árbol de selección
// Usado por los subtabs PDF y Fotos.
// ════════════════════════════════
function isSafari() {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}
function createDescargaGestor(config) {
const {
selectorId, summaryId, summaryCountId, summaryLeavesId,
btnGenerateId, btnAllId, btnNoneId, errorId,
generateUrl, generateBtnText, fallbackFilename,
extractZipOnNonSafari = false
} = config;
const state = {
arbol: [],
productos: [],
seleccionHojas: new Set()
};
function contarProductosEnRama(catId, subArr) {
return state.productos.filter(p => {
if (!p.cat || p.cat === 'sin-categoria') return false;
if (!p.img) return false;
if (p.img.startsWith('agotados/')) return false;
if (p.cat !== catId) return false;
const pSub = Array.isArray(p.sub) ? p.sub : [];
if (subArr.length === 0) return true;
if (pSub.length < subArr.length) return false;
for (let i = 0; i < subArr.length; i++) {
if (pSub[i] !== subArr[i]) return false;
}
return true;
}).length;
}
function renderNodoHtml(nodo, pathArr) {
const key = pathArr.join('|');
const catId = pathArr[0];
const subArr = pathArr.slice(1);
const tieneHijos = Array.isArray(nodo.subcategorias) && nodo.subcategorias.length > 0;
const count = tieneHijos
? nodo.subcategorias.reduce(
(sum, s) => sum + contarProductosEnRama(catId, [...subArr, s.id]),
0
)
: contarProductosEnRama(catId, subArr);
const isLeaf = !tieneHijos;
const disabled = count === 0 ? 'disabled' : '';
const countCls = count === 0 ? 'is-zero' : '';
let html = `
<div class="pdf-node">
<label class="pdf-check">
<input type="checkbox"
data-key="${escapeAttr(key)}"
data-has-children="${tieneHijos ? '1' : '0'}"
data-leaf="${isLeaf ? '1' : '0'}"
${disabled}>
</label>
<span class="pdf-node-label">
<span class="pdf-node-icon">${nodo.icono || '📦'}</span>
<span class="pdf-node-name">${escapeHtml(nodo.nombre)}</span>
<span class="pdf-node-count ${countCls}">${count}</span>
</span>
</div>
`;
if (tieneHijos) {
const childrenHtml = nodo.subcategorias
.map(s => renderNodoHtml(s, [...pathArr, s.id]))
.join('');
html += `<div class="pdf-node-children">${childrenHtml}</div>`;
}
return html;
}
async function cargarArbol() {
const cont = document.getElementById(selectorId);
cont.innerHTML = '<p class="pdf-selector-loading">Cargando categorías…</p>';
try {
const [resCats, resProds] = await Promise.all([
fetch('/api/categorias', { credentials: 'include' }),
fetch('/api/productos', { credentials: 'include' })
]);
if (!resCats.ok) throw new Error('Error al cargar categorías');
if (!resProds.ok) throw new Error('Error al cargar productos');
state.arbol = await resCats.json();
state.productos = await resProds.json();
state.seleccionHojas = new Set();
renderArbol();
} catch (err) {
console.error(`[${selectorId}] Error cargando datos:`, err);
cont.innerHTML = '<p class="pdf-selector-empty">Error al cargar. Recarga la pestaña.</p>';
}
}
function renderArbol() {
const cont = document.getElementById(selectorId);
const catsVisibles = state.arbol.filter(c => c.id !== 'sin-categoria');
if (catsVisibles.length === 0) {
cont.innerHTML = '<p class="pdf-selector-empty">No hay categorías para mostrar.</p>';
return;
}
cont.innerHTML = catsVisibles.map(cat => renderNodoHtml(cat, [cat.id])).join('');
cont.querySelectorAll('.pdf-check input').forEach(input => {
input.addEventListener('change', onCheckChange);
});
actualizarResumen();
}
function onCheckChange(e) {
const input = e.target;
const key = input.dataset.key;
const hasChildren = input.dataset.hasChildren === '1';
const isChecked = input.checked;
if (input.disabled) {
input.checked = false;
return;
}
if (hasChildren) {
setDescendantsChecked(key, isChecked);
} else {
if (isChecked) {
state.seleccionHojas.add(key);
} else {
state.seleccionHojas.delete(key);
}
}
input.indeterminate = false;
updateAncestorsState();
actualizarResumen();
}
function setDescendantsChecked(parentKey, isChecked) {
const cont = document.getElementById(selectorId);
const inputs = cont.querySelectorAll(`.pdf-check input[data-key^="${CSS.escape(parentKey)}|"]`);
inputs.forEach(child => {
if (child.disabled) return;
child.checked = isChecked;
child.indeterminate = false;
if (child.dataset.leaf === '1') {
if (isChecked) {
state.seleccionHojas.add(child.dataset.key);
} else {
state.seleccionHojas.delete(child.dataset.key);
}
}
});
}
function updateAncestorsState() {
const cont = document.getElementById(selectorId);
const inputs = cont.querySelectorAll('.pdf-check input[data-has-children="1"]');
inputs.forEach(input => {
const key = input.dataset.key;
const leafInputs = cont.querySelectorAll(
`.pdf-check input[data-leaf="1"][data-key^="${CSS.escape(key)}|"]`
);
let checked = 0, total = 0;
leafInputs.forEach(leaf => {
if (leaf.disabled) return;
total++;
if (leaf.checked) checked++;
});
input.checked = total > 0 && checked === total;
input.indeterminate = checked > 0 && checked < total;
});
}
function marcarTodo() {
const cont = document.getElementById(selectorId);
cont.querySelectorAll('.pdf-check input').forEach(input => {
if (input.disabled) return;
input.checked = true;
input.indeterminate = false;
if (input.dataset.leaf === '1') {
state.seleccionHojas.add(input.dataset.key);
}
});
updateAncestorsState();
actualizarResumen();
}
function desmarcarTodo() {
state.seleccionHojas.clear();
const cont = document.getElementById(selectorId);
cont.querySelectorAll('.pdf-check input').forEach(input => {
input.checked = false;
input.indeterminate = false;
});
actualizarResumen();
}
function actualizarResumen() {
const summary = document.getElementById(summaryId);
const countEl = document.getElementById(summaryCountId);
const leavesEl = document.getElementById(summaryLeavesId);
let totalProductos = 0;
for (const key of state.seleccionHojas) {
const parts = key.split('|');
const catId = parts[0];
const subArr = parts.slice(1);
totalProductos += contarProductosEnRama(catId, subArr);
}
if (state.seleccionHojas.size === 0) {
summary.style.display = 'none';
} else {
summary.style.display = '';
countEl.textContent = totalProductos;
leavesEl.textContent = state.seleccionHojas.size;
}
const btn = document.getElementById(btnGenerateId);
if (btn) btn.disabled = state.seleccionHojas.size === 0;
}
async function generar() {
const btn = document.getElementById(btnGenerateId);
const errEl = document.getElementById(errorId);
errEl.textContent = '';
errEl.classList.remove('show');
if (state.seleccionHojas.size === 0) {
errEl.textContent = 'Selecciona al menos una hoja';
errEl.classList.add('show');
return;
}
const seleccion = Array.from(state.seleccionHojas).map(key => {
const parts = key.split('|');
return { cat: parts[0], sub: parts.slice(1) };
});
btn.disabled = true;
const spinner = btn.querySelector('.btn-spinner');
const btnText = btn.querySelector('.btn-text');
btnText.textContent = 'Generando…';
if (spinner) spinner.style.display = 'inline-block';
try {
const res = await fetch(generateUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ seleccion })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Error al generar la descarga');
}
const blob = await res.blob();
if (extractZipOnNonSafari && !isSafari()) {
const zip = await JSZip.loadAsync(blob);
const entries = [];
zip.forEach((relPath, zipEntry) => entries.push({ relPath, zipEntry }));
btnText.textContent = `Extrayendo ${entries.length} foto(s)…`;
for (const { relPath, zipEntry } of entries) {
const imgBlob = await zipEntry.async('blob');
const imgUrl = URL.createObjectURL(imgBlob);
const a = document.createElement('a');
a.href = imgUrl;
a.download = relPath;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(imgUrl);
await new Promise(r => setTimeout(r, 300));
}
} else {
const dispo = res.headers.get('Content-Disposition') || '';
const match = dispo.match(/filename="(.+)"/);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = match ? match[1] : fallbackFilename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
} catch (err) {
errEl.textContent = err.message;
errEl.classList.add('show');
} finally {
btn.disabled = false;
btnText.textContent = generateBtnText;
if (spinner) spinner.style.display = 'none';
actualizarResumen();
}
}
function setup() {
const btnGen = document.getElementById(btnGenerateId);
const btnAll = document.getElementById(btnAllId);
const btnNone = document.getElementById(btnNoneId);
if (btnGen && !btnGen.dataset.wired) {
btnGen.addEventListener('click', generar);
btnGen.dataset.wired = '1';
}
if (btnAll && !btnAll.dataset.wired) {
btnAll.addEventListener('click', marcarTodo);
btnAll.dataset.wired = '1';
}
if (btnNone && !btnNone.dataset.wired) {
btnNone.addEventListener('click', desmarcarTodo);
btnNone.dataset.wired = '1';
}
cargarArbol();
}
return { setup, state, marcarTodo, desmarcarTodo, generar };
}
// Instancias del gestor para cada subtab
let pdfGestor;
let fotosGestor;
// Inicialización diferida en cargarDescargas()
function initGestores() {
if (pdfGestor) return;
pdfGestor = createDescargaGestor({
selectorId: 'pdfSelector',
summaryId: 'pdfSummary',
summaryCountId: 'pdfSummaryCount',
summaryLeavesId: 'pdfSummaryLeaves',
btnGenerateId: 'btnPdfGenerar',
btnAllId: 'btnPdfMarcarTodo',
btnNoneId: 'btnPdfDesmarcarTodo',
errorId: 'pdfError',
generateUrl: '/api/descargas/pdf',
generateBtnText: '📄 Generar PDF',
fallbackFilename: 'catalogo-infinity.pdf'
});
fotosGestor = createDescargaGestor({
selectorId: 'fotosSelector',
summaryId: 'fotosSummary',
summaryCountId: 'fotosSummaryCount',
summaryLeavesId: 'fotosSummaryLeaves',
btnGenerateId: 'btnFotosGenerar',
btnAllId: 'btnFotosMarcarTodo',
btnNoneId: 'btnFotosDesmarcarTodo',
errorId: 'fotosError',
generateUrl: '/api/descargas/fotos',
generateBtnText: '🖼️ Descargar fotos',
fallbackFilename: 'fotos-infinity.zip',
extractZipOnNonSafari: true
});
}
// cargarDescargas() ahora usa los gestores
// (definido arriba en la sección DESCARGAS)
+64 -9
View File
@@ -273,20 +273,74 @@
<div class="subtab-panel active" data-subtab-panel="pdf">
<div class="view-content-card">
<div class="subtab-placeholder">
<span class="subtab-placeholder-icon">📄</span>
<h3>Descargas en PDF</h3>
<p>Próximamente: herramientas para generar y descargar PDFs del catálogo.</p>
<div class="pdf-intro">
<div class="pdf-intro-icon">📄</div>
<div>
<h3 class="pdf-intro-title">Generar catálogo en PDF</h3>
<p class="pdf-intro-desc">Marca las categorías y subcategorías que quieres incluir. El PDF se generará al instante con foto, referencia, nombre y precio de cada producto.</p>
</div>
</div>
<div class="pdf-selector" id="pdfSelector">
<p class="pdf-selector-loading">Cargando categorías…</p>
</div>
<div class="pdf-summary" id="pdfSummary" style="display:none">
<span class="pdf-summary-text">
<strong id="pdfSummaryCount">0</strong> producto(s) seleccionado(s) en <strong id="pdfSummaryLeaves">0</strong> hoja(s)
</span>
</div>
<div class="form-error" id="pdfError" role="alert"></div>
<div class="pdf-actions">
<button type="button" class="btn btn-ghost" id="btnPdfDesmarcarTodo">
<span></span> Desmarcar todo
</button>
<button type="button" class="btn btn-ghost" id="btnPdfMarcarTodo">
<span></span> Marcar todo
</button>
<button type="button" class="btn btn-primary btn-pdf-generar" id="btnPdfGenerar" disabled>
<span class="btn-text">📄 Generar PDF</span>
<span class="btn-spinner" style="display:none"></span>
</button>
</div>
</div>
</div>
<div class="subtab-panel" data-subtab-panel="fotos">
<div class="view-content-card">
<div class="subtab-placeholder">
<span class="subtab-placeholder-icon">🖼️</span>
<h3>Descargas de fotos</h3>
<p>Próximamente: herramientas para descargar lotes de fotos del catálogo.</p>
<div class="pdf-intro">
<div class="pdf-intro-icon">🖼️</div>
<div>
<h3 class="pdf-intro-title">Descargar fotos del catálogo</h3>
<p class="pdf-intro-desc">Marca las categorías y subcategorías para descargar un ZIP con las fotos de los productos seleccionados.</p>
</div>
</div>
<div class="pdf-selector" id="fotosSelector">
<p class="pdf-selector-loading">Cargando categorías…</p>
</div>
<div class="pdf-summary" id="fotosSummary" style="display:none">
<span class="pdf-summary-text">
<strong id="fotosSummaryCount">0</strong> producto(s) seleccionado(s) en <strong id="fotosSummaryLeaves">0</strong> hoja(s)
</span>
</div>
<div class="form-error" id="fotosError" role="alert"></div>
<div class="pdf-actions">
<button type="button" class="btn btn-ghost" id="btnFotosDesmarcarTodo">
<span></span> Desmarcar todo
</button>
<button type="button" class="btn btn-ghost" id="btnFotosMarcarTodo">
<span></span> Marcar todo
</button>
<button type="button" class="btn btn-primary btn-pdf-generar" id="btnFotosGenerar" disabled>
<span class="btn-text">🖼️ Descargar fotos</span>
<span class="btn-spinner" style="display:none"></span>
</button>
</div>
</div>
</div>
@@ -622,6 +676,7 @@
</div>
</div>
<script src="admin.js?v=16"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="admin.js?v=19"></script>
</body>
</html>