423 lines
18 KiB
HTML
423 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>CarritoIA</title>
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||
</head>
|
||
<body>
|
||
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:.25rem;">
|
||
<h1>CarritoIA</h1>
|
||
<div style="display:flex; align-items:center; gap:.75rem;">
|
||
{% if session.get('usuario') == 'admin' %}
|
||
<a href="/admin" class="btn btn-secondary btn-sm">⚙️ Admin</a>
|
||
{% endif %}
|
||
<a href="/estadisticas" class="btn btn-secondary btn-sm">📊 Estadísticas</a>
|
||
<a href="/perfil" class="btn btn-secondary btn-sm">👤 Perfil</a>
|
||
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
|
||
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
|
||
</div>
|
||
</div>
|
||
<p class="subtitle" id="subtitulo">Cargando predicciones...</p>
|
||
|
||
<div class="toolbar">
|
||
<button class="btn btn-primary btn-sm" onclick="marcarSemanal()">Marcar sugeridos esta semana</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="desmarcarTodo()">Desmarcar todo</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="toggleTodos()" id="btnToggle">Plegar todo</button>
|
||
</div>
|
||
|
||
<div class="layout">
|
||
|
||
<div id="columnaProductos">
|
||
<div class="no-datos" id="sinDatos" style="display:none;">
|
||
<p>No hay predicciones generadas todavia.</p>
|
||
<p>Ejecuta el pipeline en el servidor o usa el boton de abajo.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
|
||
<h2>Lista de la compra <span class="count-badge" id="contadorSeleccionados">0</span></h2>
|
||
<textarea id="listaGenerada" placeholder="Marca productos a la izquierda..."></textarea>
|
||
<div style="margin-top:.75rem; display:flex; gap:.5rem;">
|
||
<button class="btn btn-primary" onclick="copiarLista()">Copiar</button>
|
||
<button class="btn btn-secondary" onclick="limpiarLista()">Limpiar</button>
|
||
</div>
|
||
|
||
<h2>Anadir manualmente</h2>
|
||
<div class="add-row">
|
||
<input type="text" id="nuevoProducto" placeholder="Producto..."
|
||
onkeydown="if(event.key==='Enter') agregarManual()">
|
||
<button class="btn btn-secondary btn-sm" onclick="agregarManual()">Anadir</button>
|
||
</div>
|
||
|
||
<h2>Subir ticket PDF</h2>
|
||
<div class="drop-zone" id="dropZone"
|
||
ondragover="dzOver(event)" ondragleave="dzLeave()" ondrop="dzDrop(event)">
|
||
<input type="file" id="ticketPDF" accept="application/pdf"
|
||
onchange="leerPDF(this.files[0])">
|
||
<span class="drop-zone-icon">📄</span>
|
||
Arrastra el PDF aqui o haz clic para seleccionarlo
|
||
</div>
|
||
<div id="pdfEstado" style="font-size:.8rem; color:#8b949e; margin-top:.4rem;"></div>
|
||
<pre id="pdfTexto"></pre>
|
||
|
||
<h2>Subir foto del ticket 📷</h2>
|
||
<div class="drop-zone" id="dropZoneFoto"
|
||
ondragover="dzOver(event)" ondragleave="dzLeaveFoto()" ondrop="dzDropFoto(event)">
|
||
<input type="file" id="ticketFoto" accept="image/jpeg,image/png,image/webp"
|
||
capture="environment" onchange="subirFoto(this.files[0])">
|
||
<span class="drop-zone-icon">📷</span>
|
||
Foto del ticket (JPG, PNG, WEBP)
|
||
</div>
|
||
<div id="fotoEstado" style="font-size:.8rem; color:#8b949e; margin-top:.4rem;"></div>
|
||
<div id="fotoProductos" style="margin-top:.5rem;"></div>
|
||
|
||
<div style="margin-top:1.25rem; border-top:1px solid var(--border); padding-top:1rem;">
|
||
<button class="btn btn-secondary btn-sm" onclick="importarEmail()" id="btnImportarEmail">
|
||
📧 Importar tickets del correo
|
||
</button>
|
||
<div id="importarEmailEstado" style="font-size:.8rem; color:var(--text-muted); margin-top:.4rem;"></div>
|
||
</div>
|
||
|
||
<div style="margin-top:1rem; border-top:1px solid var(--border); padding-top:1rem;">
|
||
<button class="btn btn-secondary btn-sm" onclick="regenerar()" id="btnRegenerar">
|
||
Regenerar predicciones
|
||
</button>
|
||
<div id="regenerarEstado" style="font-size:.8rem; color:var(--text-muted); margin-top:.4rem;"></div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.10.377/pdf.min.js"></script>
|
||
<script>
|
||
// -----------------------------------------------------------------------
|
||
// Estado
|
||
// -----------------------------------------------------------------------
|
||
const seleccionados = new Set();
|
||
let productosManuales = [];
|
||
let predicciones = [];
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Carga de datos desde la API
|
||
// -----------------------------------------------------------------------
|
||
window.addEventListener('load', async () => {
|
||
try {
|
||
const res = await fetch('/api/datos');
|
||
if (res.status === 401) { location.href = '/login'; return; }
|
||
const datos = await res.json();
|
||
predicciones = datos.predicciones || [];
|
||
if (!predicciones.length) {
|
||
document.getElementById('sinDatos').style.display = 'block';
|
||
document.getElementById('subtitulo').textContent = 'Sin datos todavia';
|
||
return;
|
||
}
|
||
if (datos.generado) {
|
||
document.getElementById('subtitulo').textContent =
|
||
'Generado el ' + datos.generado + ' — ' + predicciones.length + ' productos';
|
||
}
|
||
renderizarPredicciones();
|
||
} catch(e) {
|
||
document.getElementById('subtitulo').textContent = 'Error al cargar datos';
|
||
}
|
||
});
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Renderizado
|
||
// -----------------------------------------------------------------------
|
||
function renderizarPredicciones() {
|
||
const contenedor = document.getElementById('columnaProductos');
|
||
contenedor.innerHTML = '';
|
||
const grupos = [
|
||
{ titulo: 'Compra semanal', badge: 'badge-rojo', label: 'cada semana', items: predicciones.filter(p => p.frecuencia_dias <= 8) },
|
||
{ titulo: 'Compra quincenal', badge: 'badge-naranja', label: 'cada 1-2 semanas', items: predicciones.filter(p => p.frecuencia_dias > 8 && p.frecuencia_dias <= 16) },
|
||
{ titulo: 'Compra mensual', badge: 'badge-verde', label: 'cada 2-4 semanas', items: predicciones.filter(p => p.frecuencia_dias > 16 && p.frecuencia_dias <= 35) },
|
||
{ titulo: 'Compra esporadica', badge: 'badge-gris', label: 'mas de un mes', items: predicciones.filter(p => p.frecuencia_dias > 35) },
|
||
];
|
||
grupos.forEach((grupo, idx) => {
|
||
if (!grupo.items.length) return;
|
||
const card = document.createElement('div');
|
||
card.className = 'card' + (idx >= 2 ? ' collapsed' : '');
|
||
card.innerHTML =
|
||
'<div class="card-header">' +
|
||
'<strong>' + grupo.titulo + '</strong>' +
|
||
'<span class="badge ' + grupo.badge + '">' + grupo.label + '</span>' +
|
||
'<span class="card-count">' + grupo.items.length + '</span>' +
|
||
'<span class="card-chevron">▾</span>' +
|
||
'</div>' +
|
||
'<div class="prod-list"></div>';
|
||
card.querySelector('.card-header').addEventListener('click', () => {
|
||
card.classList.toggle('collapsed');
|
||
actualizarBotonToggle();
|
||
});
|
||
contenedor.appendChild(card);
|
||
const lista = card.querySelector('.prod-list');
|
||
grupo.items.forEach(prod => lista.appendChild(crearItem(prod)));
|
||
});
|
||
}
|
||
|
||
function toggleTodos() {
|
||
const cards = [...document.querySelectorAll('#columnaProductos .card')];
|
||
const todosCollapsed = cards.every(c => c.classList.contains('collapsed'));
|
||
cards.forEach(c => c.classList.toggle('collapsed', !todosCollapsed));
|
||
actualizarBotonToggle();
|
||
}
|
||
|
||
function actualizarBotonToggle() {
|
||
const cards = [...document.querySelectorAll('#columnaProductos .card')];
|
||
const todosCollapsed = cards.length > 0 && cards.every(c => c.classList.contains('collapsed'));
|
||
document.getElementById('btnToggle').textContent = todosCollapsed ? 'Desplegar todo' : 'Plegar todo';
|
||
}
|
||
|
||
function crearItem(prod) {
|
||
const li = document.createElement('div');
|
||
li.className = 'prod-item';
|
||
li.dataset.nombre = prod.producto;
|
||
|
||
const chk = document.createElement('input');
|
||
chk.type = 'checkbox';
|
||
chk.checked = seleccionados.has(prod.producto);
|
||
chk.addEventListener('change', () => toggleSeleccionado(prod.producto, chk.checked, span));
|
||
|
||
const span = document.createElement('span');
|
||
span.className = 'prod-nombre' + (chk.checked ? ' tachado' : '');
|
||
span.textContent = prod.producto;
|
||
|
||
const freq = document.createElement('span');
|
||
freq.className = 'prod-freq';
|
||
freq.textContent = '~' + prod.frecuencia_dias + 'd';
|
||
freq.title = 'Estimado: ' + prod.fecha_estimada;
|
||
|
||
li.appendChild(chk);
|
||
li.appendChild(span);
|
||
if (prod.es_estacional) {
|
||
const badge = document.createElement('span');
|
||
badge.className = 'badge-temporada';
|
||
badge.textContent = '🌱';
|
||
badge.title = 'Producto de temporada (meses: ' + prod.meses_temporada + ')';
|
||
li.appendChild(badge);
|
||
}
|
||
li.appendChild(freq);
|
||
li.addEventListener('click', e => {
|
||
if (e.target !== chk) { chk.checked = !chk.checked; toggleSeleccionado(prod.producto, chk.checked, span); }
|
||
});
|
||
return li;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Seleccion
|
||
// -----------------------------------------------------------------------
|
||
function toggleSeleccionado(nombre, checked, span) {
|
||
if (checked) { seleccionados.add(nombre); span && span.classList.add('tachado'); }
|
||
else { seleccionados.delete(nombre); span && span.classList.remove('tachado'); }
|
||
actualizarLista();
|
||
}
|
||
|
||
function marcarSemanal() {
|
||
predicciones.filter(p => p.frecuencia_dias <= 8).forEach(p => seleccionados.add(p.producto));
|
||
refrescarCheckboxes(); actualizarLista();
|
||
}
|
||
|
||
function desmarcarTodo() {
|
||
seleccionados.clear(); productosManuales = [];
|
||
refrescarCheckboxes(); actualizarLista();
|
||
}
|
||
|
||
function refrescarCheckboxes() {
|
||
document.querySelectorAll('.prod-item').forEach(li => {
|
||
const chk = li.querySelector('input[type="checkbox"]');
|
||
const span = li.querySelector('.prod-nombre');
|
||
const activo = seleccionados.has(li.dataset.nombre);
|
||
chk.checked = activo;
|
||
span.classList.toggle('tachado', activo);
|
||
});
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Lista
|
||
// -----------------------------------------------------------------------
|
||
function actualizarLista() {
|
||
const todos = [...seleccionados, ...productosManuales];
|
||
document.getElementById('listaGenerada').value = todos.join('\n');
|
||
document.getElementById('contadorSeleccionados').textContent = todos.length;
|
||
}
|
||
|
||
function copiarLista() {
|
||
const txt = document.getElementById('listaGenerada').value;
|
||
if (!txt) return;
|
||
navigator.clipboard.writeText(txt)
|
||
.then(() => alert('Lista copiada al portapapeles'))
|
||
.catch(() => { const ta = document.getElementById('listaGenerada'); ta.select(); document.execCommand('copy'); });
|
||
}
|
||
|
||
function limpiarLista() {
|
||
seleccionados.clear(); productosManuales = [];
|
||
refrescarCheckboxes(); actualizarLista();
|
||
}
|
||
|
||
function agregarManual() {
|
||
const input = document.getElementById('nuevoProducto');
|
||
const nombre = input.value.trim();
|
||
if (!nombre) return;
|
||
productosManuales.push(nombre);
|
||
input.value = '';
|
||
actualizarLista();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Regenerar pipeline
|
||
// -----------------------------------------------------------------------
|
||
async function regenerar() {
|
||
const btn = document.getElementById('btnRegenerar');
|
||
const estado = document.getElementById('regenerarEstado');
|
||
btn.disabled = true;
|
||
estado.textContent = 'Ejecutando pipeline...';
|
||
try {
|
||
const res = await fetch('/api/regenerar', { method: 'POST' });
|
||
const data = await res.json();
|
||
if (data.ok) {
|
||
estado.textContent = '✅ ' + data.mensaje;
|
||
setTimeout(() => location.reload(), 1500);
|
||
} else {
|
||
estado.style.color = '#f97316';
|
||
estado.textContent = '❌ ' + data.mensaje;
|
||
if (data.detalle) {
|
||
estado.innerHTML += '<pre style="font-size:.7rem;white-space:pre-wrap;margin-top:.3rem;color:#f97316;">' + data.detalle + '</pre>';
|
||
}
|
||
}
|
||
} catch(e) {
|
||
estado.textContent = 'Error de conexion';
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Drag & Drop + lectura PDF
|
||
// -----------------------------------------------------------------------
|
||
function dzOver(e) { e.preventDefault(); document.getElementById('dropZone').classList.add('drag-over'); }
|
||
function dzLeave() { document.getElementById('dropZone').classList.remove('drag-over'); }
|
||
function dzDrop(e) {
|
||
e.preventDefault(); dzLeave();
|
||
const file = e.dataTransfer.files[0];
|
||
if (file && file.type === 'application/pdf') leerPDF(file);
|
||
}
|
||
|
||
async function leerPDF(file) {
|
||
if (!file) return;
|
||
const estado = document.getElementById('pdfEstado');
|
||
estado.style.color = 'var(--text-muted)';
|
||
estado.textContent = 'Leyendo ' + file.name + '...';
|
||
|
||
// Subir al servidor en paralelo
|
||
const form = new FormData();
|
||
form.append('pdf', file);
|
||
const subirPromise = fetch('/api/subir-pdf', { method: 'POST', body: form });
|
||
|
||
// Extraer texto localmente con pdf.js
|
||
const pdfData = await file.arrayBuffer();
|
||
const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise;
|
||
let texto = '';
|
||
for (let i = 1; i <= pdf.numPages; i++) {
|
||
const page = await pdf.getPage(i);
|
||
const content = await page.getTextContent();
|
||
texto += content.items.map(item => item.str).join(' ') + '\n';
|
||
}
|
||
const salida = document.getElementById('pdfTexto');
|
||
salida.style.display = 'block';
|
||
salida.textContent = texto.trim();
|
||
|
||
// Resultado de la subida
|
||
try {
|
||
const res = await subirPromise;
|
||
const data = await res.json();
|
||
if (data.ok) {
|
||
estado.textContent = '✅ ' + file.name + ' guardado en el servidor (' + pdf.numPages + ' pag.) — Pulsa "Regenerar predicciones" para actualizar.';
|
||
} else {
|
||
estado.style.color = '#f97316';
|
||
estado.textContent = '⚠️ ' + file.name + ' leido localmente pero no se pudo guardar en el servidor: ' + data.mensaje;
|
||
}
|
||
} catch(e) {
|
||
estado.style.color = '#f97316';
|
||
estado.textContent = '⚠️ ' + file.name + ' leido localmente pero no se pudo subir al servidor.';
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Subir foto del ticket (OCR en servidor)
|
||
// -----------------------------------------------------------------------
|
||
function dzLeaveFoto() { document.getElementById('dropZoneFoto').classList.remove('drag-over'); }
|
||
function dzDropFoto(e) {
|
||
e.preventDefault(); dzLeaveFoto();
|
||
const file = e.dataTransfer.files[0];
|
||
if (file && file.type.startsWith('image/')) subirFoto(file);
|
||
}
|
||
|
||
async function subirFoto(file) {
|
||
if (!file) return;
|
||
const estado = document.getElementById('fotoEstado');
|
||
const productos = document.getElementById('fotoProductos');
|
||
estado.textContent = '⏳ Procesando con OCR... (puede tardar unos segundos)';
|
||
productos.innerHTML = '';
|
||
|
||
const form = new FormData();
|
||
form.append('imagen', file);
|
||
|
||
try {
|
||
const res = await fetch('/api/ocr-ticket', { method: 'POST', body: form });
|
||
const data = await res.json();
|
||
|
||
if (data.ok) {
|
||
estado.textContent = '✅ ' + data.mensaje + ' (fecha: ' + data.fecha + ') — Pulsa "Regenerar predicciones" para actualizar la lista.';
|
||
productos.innerHTML = '<details style="margin-top:.4rem;"><summary style="cursor:pointer;font-size:.8rem;color:var(--text-muted)">Ver productos detectados</summary><ul style="font-size:.8rem;margin:.4rem 0 0 1rem;color:var(--text-muted);">' +
|
||
data.productos.map(p => `<li>${p.cantidad}× ${p.producto} — ${p.precio_total.toFixed(2)} €</li>`).join('') +
|
||
'</ul></details>';
|
||
} else {
|
||
estado.textContent = '❌ ' + data.mensaje;
|
||
if (data.texto) {
|
||
productos.innerHTML = '<details style="margin-top:.4rem;"><summary style="cursor:pointer;font-size:.8rem;color:var(--text-muted)">Texto extraido (sin productos)</summary><pre style="font-size:.72rem;color:var(--text-muted);white-space:pre-wrap;">' + data.texto + '</pre></details>';
|
||
}
|
||
}
|
||
} catch(e) {
|
||
estado.textContent = '❌ Error de conexion';
|
||
}
|
||
}
|
||
// -----------------------------------------------------------------------
|
||
// Importar tickets desde correo
|
||
// -----------------------------------------------------------------------
|
||
async function importarEmail() {
|
||
const btn = document.getElementById('btnImportarEmail');
|
||
const estado = document.getElementById('importarEmailEstado');
|
||
btn.disabled = true;
|
||
estado.style.color = 'var(--text-muted)';
|
||
estado.textContent = '⏳ Conectando con el correo...';
|
||
try {
|
||
const res = await fetch('/api/importar-email', { method: 'POST' });
|
||
const data = await res.json();
|
||
if (data.ok) {
|
||
if (data.nuevos > 0) {
|
||
estado.textContent = '✅ ' + data.mensaje + ' — regenerando predicciones...';
|
||
await fetch('/api/regenerar', { method: 'POST' });
|
||
setTimeout(() => location.reload(), 1200);
|
||
} else {
|
||
estado.textContent = '✅ No hay tickets nuevos en el correo.';
|
||
}
|
||
} else {
|
||
estado.style.color = '#f97316';
|
||
estado.textContent = '❌ ' + data.mensaje;
|
||
if (data.detalle) {
|
||
estado.innerHTML += '<pre style="font-size:.7rem;white-space:pre-wrap;margin-top:.3rem;color:#f97316;">' + data.detalle + '</pre>';
|
||
}
|
||
}
|
||
} catch(e) {
|
||
estado.textContent = '❌ Error de conexion';
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |