dockerizando
This commit is contained in:
parent
6930c63ca5
commit
d25abab68a
|
|
@ -0,0 +1,3 @@
|
|||
# Copia este archivo como .env y rellena los valores
|
||||
SECRET_KEY=pon-aqui-una-clave-larga-y-aleatoria
|
||||
ADMIN_PASSWORD=tu-password-de-admin
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# Datos sensibles — nunca al repo
|
||||
config.ini
|
||||
users.json
|
||||
*.pdf
|
||||
tickets/
|
||||
|
||||
# Generados
|
||||
datos.js
|
||||
datos.json
|
||||
lista_compra_estimado.csv
|
||||
lista_compra_estimado.csv
|
||||
*.csv
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Crear carpetas que necesita la app
|
||||
RUN mkdir -p tickets
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "--timeout", "120", "app:app"]
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
services:
|
||||
|
||||
autocompra:
|
||||
build: .
|
||||
container_name: autocompra
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
ADMIN_PASSWORD: "${ADMIN_PASSWORD}"
|
||||
volumes:
|
||||
# Persistir tickets, datos generados y configuracion fuera del contenedor
|
||||
- ./tickets:/app/tickets
|
||||
- ./datos.json:/app/datos.json
|
||||
- ./users.json:/app/users.json
|
||||
- ./config.ini:/app/config.ini
|
||||
- ./lista_compra_estimado.csv:/app/lista_compra_estimado.csv
|
||||
ports:
|
||||
- "8088:5000"
|
||||
|
|
@ -106,5 +106,14 @@ with open('datos.js', 'w', encoding='utf-8') as f:
|
|||
json.dump(resultado, f, ensure_ascii=False, indent=2)
|
||||
f.write(';\n')
|
||||
|
||||
print(f"✓ {len(resultado)} predicciones escritas en datos.js")
|
||||
print(f" Abre index.html en el navegador para ver la lista.")
|
||||
# Escribir datos.json (usado por la app Flask)
|
||||
datos_json = {
|
||||
'generado': ts,
|
||||
'total': len(resultado),
|
||||
'predicciones': resultado,
|
||||
}
|
||||
with open('datos.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(datos_json, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✓ {len(resultado)} predicciones escritas en datos.js y datos.json")
|
||||
print(f" Abre index.html en el navegador o arranca app.py para ver la lista.")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
flask>=3.0
|
||||
gunicorn>=21.0
|
||||
werkzeug>=3.0
|
||||
PyPDF2>=3.0
|
||||
pandas>=2.0
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
/* ============================================================
|
||||
Lista de la Compra Inteligente — Dark Theme
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--bg-card: #161b22;
|
||||
--bg-hover: #1c2128;
|
||||
--bg-input: #0d1117;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--primary: #388bfd;
|
||||
--primary-h: #58a6ff;
|
||||
|
||||
/* badges */
|
||||
--badge-sem-bg: #3d2b1f;
|
||||
--badge-sem-tx: #f0883e;
|
||||
--badge-qui-bg: #2d2816;
|
||||
--badge-qui-tx: #d29922;
|
||||
--badge-men-bg: #162518;
|
||||
--badge-men-tx: #3fb950;
|
||||
--badge-esp-bg: #21262d;
|
||||
--badge-esp-tx: #8b949e;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.6rem; margin: 0 0 .25rem; color: var(--text); }
|
||||
h2 {
|
||||
font-size: .85rem;
|
||||
margin: 1.5rem 0 .5rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle { color: var(--text-muted); font-size: .85rem; margin-bottom: 1.5rem; }
|
||||
|
||||
/* ---- Layout ---- */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
.card-header strong { color: var(--text); font-size: .95rem; }
|
||||
|
||||
/* ---- Badges ---- */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: .2rem .6rem;
|
||||
border-radius: 20px;
|
||||
font-size: .72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-rojo { background: var(--badge-sem-bg); color: var(--badge-sem-tx); }
|
||||
.badge-naranja { background: var(--badge-qui-bg); color: var(--badge-qui-tx); }
|
||||
.badge-verde { background: var(--badge-men-bg); color: var(--badge-men-tx); }
|
||||
.badge-gris { background: var(--badge-esp-bg); color: var(--badge-esp-tx); }
|
||||
|
||||
/* ---- Producto item ---- */
|
||||
.prod-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
padding: .45rem .25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background .1s;
|
||||
}
|
||||
.prod-item:last-child { border-bottom: none; }
|
||||
.prod-item:hover { background: var(--bg-hover); }
|
||||
.prod-item input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
.prod-nombre { flex: 1; font-size: .92rem; color: var(--text); }
|
||||
.prod-nombre.tachado { text-decoration: line-through; color: var(--text-muted); }
|
||||
.prod-freq { font-size: .72rem; color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
/* ---- Panel lateral ---- */
|
||||
.panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
.panel h2 { margin-top: 0; }
|
||||
|
||||
#listaGenerada {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
font-size: .88rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: .6rem;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
#listaGenerada:focus { outline: none; border-color: var(--primary); }
|
||||
|
||||
/* ---- Botones ---- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
padding: .5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: .88rem;
|
||||
font-weight: 500;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-h); }
|
||||
.btn-secondary {
|
||||
background: #21262d;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
margin-left: .4rem;
|
||||
}
|
||||
.btn-secondary:hover { background: #30363d; }
|
||||
.btn-sm { padding: .3rem .7rem; font-size: .8rem; }
|
||||
|
||||
/* ---- Toolbar ---- */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ---- Input añadir manual ---- */
|
||||
.add-row { display: flex; gap: .5rem; margin-top: .5rem; }
|
||||
.add-row input {
|
||||
flex: 1;
|
||||
padding: .42rem .7rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: .88rem;
|
||||
}
|
||||
.add-row input:focus { outline: none; border-color: var(--primary); }
|
||||
|
||||
/* ---- Sin datos ---- */
|
||||
.no-datos {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.no-datos code {
|
||||
display: block;
|
||||
background: var(--bg);
|
||||
color: var(--primary-h);
|
||||
border: 1px solid var(--border);
|
||||
padding: .5rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin: .75rem auto;
|
||||
width: fit-content;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
/* ---- Drag & drop zona ticket ---- */
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: .85rem;
|
||||
cursor: pointer;
|
||||
transition: border-color .2s, background .2s;
|
||||
margin-top: .5rem;
|
||||
position: relative;
|
||||
}
|
||||
.drop-zone:hover,
|
||||
.drop-zone.drag-over {
|
||||
border-color: var(--primary);
|
||||
background: #0d2044;
|
||||
color: var(--text);
|
||||
}
|
||||
.drop-zone input[type="file"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.drop-zone-icon { font-size: 1.5rem; display: block; margin-bottom: .3rem; }
|
||||
|
||||
#pdfTexto {
|
||||
white-space: pre-wrap;
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
padding: .75rem;
|
||||
border-radius: 6px;
|
||||
font-size: .78rem;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
margin-top: .75rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* ---- Count badge ---- */
|
||||
.count-badge {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
margin-left: .4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ---- Scrollbar oscura ---- */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
/* ---- Login page ---- */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
.login-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
.login-card h1 { font-size: 1.4rem; margin-bottom: .25rem; }
|
||||
.login-card label {
|
||||
display: block;
|
||||
font-size: .82rem;
|
||||
color: var(--text-muted);
|
||||
margin: .75rem 0 .25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
padding: .5rem .75rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: .92rem;
|
||||
}
|
||||
.login-card input:focus { outline: none; border-color: var(--primary); }
|
||||
.alert {
|
||||
background: #3d1f1f;
|
||||
color: #f47f7f;
|
||||
border: 1px solid #7d2020;
|
||||
border-radius: 6px;
|
||||
padding: .5rem .75rem;
|
||||
font-size: .85rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Usuarios — Lista de la Compra</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div style="max-width:480px; margin:2rem auto; padding:0 1rem;">
|
||||
<h1>Gestion de usuarios</h1>
|
||||
<a href="/" class="btn btn-secondary btn-sm" style="margin-bottom:1.5rem; display:inline-block;">← Volver</a>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>Usuarios activos</strong></div>
|
||||
{% for u in usuarios %}
|
||||
<div class="prod-item">
|
||||
<span class="prod-nombre">{{ u.nombre }} <span style="color:var(--text-muted); font-size:.8rem;">({{ u.usuario }})</span></span>
|
||||
{% if u.usuario != 'admin' %}
|
||||
<form method="post" action="/admin/usuarios/eliminar"
|
||||
onsubmit="return confirm('Eliminar a {{ u.nombre }}?')">
|
||||
<input type="hidden" name="usuario" value="{{ u.usuario }}">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Eliminar</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:1rem;">
|
||||
<div class="card-header"><strong>Crear usuario</strong></div>
|
||||
<form method="post" action="/admin/usuarios/crear" style="display:flex; flex-direction:column; gap:.6rem;">
|
||||
<input name="nombre" type="text" placeholder="Nombre" class="add-row input" style="padding:.42rem .7rem; background:var(--bg-input); color:var(--text); border:1px solid var(--border); border-radius:6px;" required>
|
||||
<input name="usuario" type="text" placeholder="Usuario (sin espacios)" style="padding:.42rem .7rem; background:var(--bg-input); color:var(--text); border:1px solid var(--border); border-radius:6px;" required>
|
||||
<input name="password" type="password" placeholder="Contrasena" style="padding:.42rem .7rem; background:var(--bg-input); color:var(--text); border:1px solid var(--border); border-radius:6px;" required>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Crear</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lista de la Compra</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>Lista de la Compra</h1>
|
||||
<div style="display:flex; align-items:center; gap:.75rem;">
|
||||
{% if session.get('usuario') == 'admin' %}
|
||||
<a href="/admin/usuarios" class="btn btn-secondary btn-sm">Usuarios</a>
|
||||
{% endif %}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div style="margin-top:1.25rem; 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 => {
|
||||
if (!grupo.items.length) return;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML =
|
||||
'<div class="card-header"><strong>' + grupo.titulo + '</strong>' +
|
||||
'<span class="badge ' + grupo.badge + '">' + grupo.label + '</span></div>' +
|
||||
'<div class="prod-list"></div>';
|
||||
contenedor.appendChild(card);
|
||||
const lista = card.querySelector('.prod-list');
|
||||
grupo.items.forEach(prod => lista.appendChild(crearItem(prod)));
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
estado.textContent = data.mensaje;
|
||||
if (data.ok) setTimeout(() => location.reload(), 1500);
|
||||
} 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.textContent = 'Leyendo ' + file.name + '...';
|
||||
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';
|
||||
}
|
||||
estado.textContent = file.name + ' — ' + pdf.numPages + ' pagina(s)';
|
||||
const salida = document.getElementById('pdfTexto');
|
||||
salida.style.display = 'block';
|
||||
salida.textContent = texto.trim();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Acceder — Lista de la Compra</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-card">
|
||||
<h1>🛒 Lista de la Compra</h1>
|
||||
<p class="subtitle">Inicia sesion para continuar</p>
|
||||
{% if error %}
|
||||
<div class="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<label for="usuario">Usuario</label>
|
||||
<input id="usuario" name="usuario" type="text"
|
||||
placeholder="tu usuario" autocomplete="username" required autofocus>
|
||||
<label for="password">Contrasena</label>
|
||||
<input id="password" name="password" type="password"
|
||||
placeholder="••••••••" autocomplete="current-password" required>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%; margin-top:.5rem;">
|
||||
Entrar
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue