Modificación o agregado de productos y materias primas
This commit is contained in:
parent
ce3d01a180
commit
9c5219863b
@ -56,6 +56,8 @@ app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), {
|
||||
maxAge: '1y'
|
||||
}));
|
||||
|
||||
const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Configuración de conexión PostgreSQL
|
||||
// ----------------------------------------------------------
|
||||
@ -228,6 +230,11 @@ app.get("/estadoComandas", (req, res) => {
|
||||
// res.sendFile(path.join(__dirname, 'pages', 'estadoComandas.html'));
|
||||
// });
|
||||
|
||||
app.get("/productos", (req, res) => {
|
||||
res.locals.pageTitle = "Productos";
|
||||
res.locals.pageId = "productos";
|
||||
res.render("productos");
|
||||
});
|
||||
// ----------------------------------------------------------
|
||||
// API
|
||||
// ----------------------------------------------------------
|
||||
@ -513,6 +520,64 @@ app.post('/api/comandas/:id/abrir', async (req, res, next) => {
|
||||
// } catch (e) { next(e); }
|
||||
// });
|
||||
|
||||
// GET producto + receta
|
||||
app.get('/api/rpc/get_producto/:id', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const { rows } = await pool.query('SELECT public.get_producto($1) AS data', [id]);
|
||||
res.json(rows[0]?.data || {});
|
||||
});
|
||||
|
||||
// POST guardar producto + receta
|
||||
|
||||
app.post('/api/rpc/save_producto', async (req, res) => {
|
||||
try {
|
||||
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
|
||||
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
|
||||
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
|
||||
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
|
||||
const { rows } = await pool.query(q, params);
|
||||
res.json(rows[0] || {});
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'save_producto failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// app.post('/api/rpc/save_producto', async (req, res) => {
|
||||
// const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
|
||||
// const q = 'SELECT * FROM public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb)';
|
||||
// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
|
||||
// const { rows } = await pool.query(q, params);
|
||||
// res.json(rows[0] || {});
|
||||
// });
|
||||
|
||||
// GET MP + proveedores
|
||||
app.get('/api/rpc/get_materia/:id', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT public.get_materia_prima($1) AS data', [id]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'get_materia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// SAVE MP + proveedores (array)
|
||||
app.post('/api/rpc/save_materia', async (req, res) => {
|
||||
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
|
||||
try {
|
||||
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
|
||||
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
|
||||
const { rows } = await pool.query(q, params);
|
||||
res.json(rows[0] || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'save_materia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
|
||||
BIN
services/manso/src/public/img/productos/img_producto.png
Normal file
BIN
services/manso/src/public/img/productos/img_producto.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1005 KiB |
559
services/manso/src/views/productos.ejs
Normal file
559
services/manso/src/views/productos.ejs
Normal file
@ -0,0 +1,559 @@
|
||||
<!-- services/manso/src/views/productos.ejs -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h1 class="h4 m-0">🛒 Productos</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="btnNuevo" class="btn btn-outline-secondary btn-sm">Nuevo</button>
|
||||
<button id="btnGuardar" class="btn btn-primary btn-sm">Guardar</button>
|
||||
<button class="btn btn-outline-dark btn-sm" data-bs-toggle="collapse" data-bs-target="#mpWrap" aria-expanded="false">Materias primas</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- ===== Listado ===== -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Listado</strong>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<input id="q" type="search" class="form-control form-control-sm" placeholder="Buscar…">
|
||||
<button id="btnLimpiar" class="btn btn-outline-secondary btn-sm">Limpiar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 65vh; overflow: auto;">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Nombre</th>
|
||||
<th class="text-end">Precio</th>
|
||||
<th>Activo</th>
|
||||
<th>Categoría</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbProductos">
|
||||
<tr><td colspan="5" class="p-3 text-muted">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Edición / Alta ===== -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><strong>Ficha</strong></div>
|
||||
<div class="card-body">
|
||||
<input type="hidden" id="id_producto">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label small text-muted mb-1" for="nombre">Nombre</label>
|
||||
<input id="nombre" class="form-control" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label small text-muted mb-1" for="precio">Precio</label>
|
||||
<input id="precio" type="number" step="0.01" min="0" class="form-control">
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label small text-muted mb-1" for="id_categoria">Categoría</label>
|
||||
<select id="id_categoria" class="form-select"></select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small text-muted mb-1" for="img_producto">Imagen (URL)</label>
|
||||
<input id="img_producto" class="form-control" placeholder="img_producto.png">
|
||||
<div class="mt-2">
|
||||
<img id="preview" src="" alt="" class="img-thumbnail d-none" style="max-height: 140px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input id="activo" class="form-check-input" type="checkbox" checked>
|
||||
<label for="activo" class="form-check-label">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Receta ===== -->
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Receta (materias primas por unidad)</strong>
|
||||
<button id="btnAddIng" class="btn btn-outline-secondary btn-sm ms-auto">Agregar ingrediente</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 55%;">Materia prima</th>
|
||||
<th class="text-end" style="width: 25%;">Cantidad</th>
|
||||
<th style="width: 20%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbReceta">
|
||||
<tr><td colspan="3" class="p-3 text-muted">Sin ingredientes.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex align-items-center small text-muted">
|
||||
Cantidades en la unidad definida por cada materia prima.
|
||||
<span id="msg" class="ms-auto"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== GESTIÓN DE MATERIAS PRIMAS (OCULTO POR DEFECTO) ====== -->
|
||||
<div class="collapse mt-4" id="mpWrap">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<h2 class="h5 m-0">⚙️ Materias primas</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="mpNuevo" class="btn btn-outline-secondary btn-sm">Nuevo</button>
|
||||
<button id="mpGuardar" class="btn btn-primary btn-sm">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Listado MP -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Listado</strong>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<input id="mpQ" type="search" class="form-control form-control-sm" placeholder="Buscar…">
|
||||
<button id="mpLimpiar" class="btn btn-outline-secondary btn-sm">Limpiar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height:60vh;overflow:auto;">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Nombre</th>
|
||||
<th>Unidad</th>
|
||||
<th>Activo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mpTb">
|
||||
<tr><td colspan="4" class="p-3 text-muted">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ficha MP -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><strong>Ficha</strong></div>
|
||||
<div class="card-body">
|
||||
<input type="hidden" id="mp_id_mat_prima">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label small text-muted mb-1" for="mp_nombre">Nombre</label>
|
||||
<input id="mp_nombre" class="form-control" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label small text-muted mb-1" for="mp_unidad">Unidad</label>
|
||||
<input id="mp_unidad" class="form-control" placeholder="ej: gr, ml, u.">
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input id="mp_activo" class="form-check-input" type="checkbox" checked>
|
||||
<label class="form-check-label" for="mp_activo">Activo</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label small text-muted mb-1" for="mp_proveedores">Proveedores (asignación)</label>
|
||||
<select id="mp_proveedores" class="form-select" multiple></select>
|
||||
<div class="form-text">Mantén presionadas Ctrl/⌘ para seleccionar varios.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer small text-muted d-flex">
|
||||
<span id="mpMsg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== Helpers =====
|
||||
const $ = (s, r=document)=>r.querySelector(s);
|
||||
const $$ = (s, r=document)=>Array.from(r.querySelectorAll(s));
|
||||
const money = (n)=> isNaN(n) ? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(+n);
|
||||
const toast = (t, ok=false)=> { const el=$('#msg'); el.className = 'ms-auto ' + (ok?'text-success':'text-danger'); el.textContent=t; setTimeout(()=>{ el.textContent=''; el.className='ms-auto text-muted'; }, 3000); };
|
||||
|
||||
async function jget(u){ const r=await fetch(u); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
|
||||
async function jpost(u,b){ const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
|
||||
|
||||
// ===== Estado =====
|
||||
const state = {
|
||||
productos: [],
|
||||
categorias: [],
|
||||
materias: [],
|
||||
receta: [], // [{id_mat_prima, nombre, unidad, qty_por_unidad}]
|
||||
filtro: '',
|
||||
selId: null
|
||||
};
|
||||
|
||||
// ===== Carga inicial =====
|
||||
async function init(){
|
||||
const [prods, cats, mats] = await Promise.all([
|
||||
jget('/api/table/productos?limit=2000'),
|
||||
jget('/api/table/categorias?limit=2000').catch(()=>[]),
|
||||
jget('/api/table/mate_primas?limit=2000')
|
||||
]);
|
||||
state.productos = Array.isArray(prods)? prods : [];
|
||||
state.categorias = Array.isArray(cats)? cats : [];
|
||||
state.catById = new Map(state.categorias.map(c => [c.id_categoria, c.nombre]));
|
||||
state.materias = Array.isArray(mats)? mats : [];
|
||||
|
||||
hydrateCategorias();
|
||||
renderLista();
|
||||
clearForm();
|
||||
}
|
||||
|
||||
function hydrateCategorias(){
|
||||
const sel = $('#id_categoria'); sel.innerHTML='';
|
||||
sel.appendChild(new Option('(sin categoría)', '', true, true));
|
||||
state.categorias.forEach(c => sel.appendChild(new Option(c.nombre || ('#'+c.id_categoria), c.id_categoria)));
|
||||
}
|
||||
|
||||
// ===== Listado =====
|
||||
const catName = (id) => state?.catById?.get(id) ?? (id ? `#${id}` : '');
|
||||
function renderLista(){
|
||||
const tb = $('#tbProductos');
|
||||
let rows = state.productos.slice();
|
||||
const f = state.filtro.trim().toLowerCase();
|
||||
if (f) rows = rows.filter(p => (p.nombre||'').toLowerCase().includes(f) || String(p.id_producto).includes(f));
|
||||
|
||||
if (!rows.length) { tb.innerHTML = '<tr><td colspan="5" class="p-3 text-muted">Sin resultados.</td></tr>'; return; }
|
||||
|
||||
tb.innerHTML = '';
|
||||
rows.forEach(p => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.cursor='pointer';
|
||||
tr.innerHTML = `
|
||||
<td>${p.id_producto}</td>
|
||||
<td>${p.nombre||'—'}</td>
|
||||
<td class="text-end">${money(p.precio)}</td>
|
||||
<td>${p.activo ? 'Sí' : 'No'}</td>
|
||||
<td>${catName(p.id_categoria)}</td>
|
||||
`;
|
||||
tr.addEventListener('click', ()=> loadProducto(p.id_producto));
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Ficha =====
|
||||
function clearForm(){
|
||||
state.selId = null;
|
||||
$('#id_producto').value = '';
|
||||
$('#nombre').value = '';
|
||||
$('#precio').value = '';
|
||||
$('#id_categoria').value = '';
|
||||
$('#img_producto').value = '';
|
||||
$('#preview').src = ''; $('#preview').classList.add('d-none');
|
||||
$('#activo').checked = true;
|
||||
state.receta = [];
|
||||
renderReceta();
|
||||
}
|
||||
|
||||
async function loadProducto(id){
|
||||
try {
|
||||
// Usamos la función SQL (RPC) para traer producto + receta en un solo tiro
|
||||
const data = await jget(`/api/rpc/get_producto/${id}`);
|
||||
const p = data.producto || {};
|
||||
const r = Array.isArray(data.receta) ? data.receta : [];
|
||||
|
||||
state.selId = p.id_producto;
|
||||
$('#id_producto').value = p.id_producto ?? '';
|
||||
$('#nombre').value = p.nombre ?? '';
|
||||
$('#precio').value = p.precio ?? '';
|
||||
$('#id_categoria').value = p.id_categoria ?? '';
|
||||
$('#img_producto').value = p.img_producto ?? '';
|
||||
if (p.img_producto) { $('#preview').src = p.img_producto; $('#preview').classList.remove('d-none'); } else { $('#preview').src=''; $('#preview').classList.add('d-none'); }
|
||||
$('#activo').checked = (p.activo !== false);
|
||||
|
||||
// receta
|
||||
state.receta = r.map(x => ({
|
||||
id_mat_prima: x.id_mat_prima,
|
||||
nombre: x.nombre ?? (state.materias.find(m=>m.id_mat_prima===x.id_mat_prima)?.nombre || ('#'+x.id_mat_prima)),
|
||||
unidad: x.unidad ?? (state.materias.find(m=>m.id_mat_prima===x.id_mat_prima)?.unidad || ''),
|
||||
qty_por_unidad: Number(x.qty_por_unidad||0)
|
||||
}));
|
||||
renderReceta();
|
||||
} catch(e) {
|
||||
toast(e.message || 'No se pudo cargar el producto');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Receta (UI) =====
|
||||
function renderReceta(){
|
||||
const tb = $('#tbReceta');
|
||||
if (!state.receta.length) { tb.innerHTML = '<tr><td colspan="3" class="p-3 text-muted">Sin ingredientes.</td></tr>'; return; }
|
||||
tb.innerHTML = '';
|
||||
|
||||
state.receta.forEach((it, idx) => {
|
||||
const tr = document.createElement('tr');
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'form-select form-select-sm';
|
||||
state.materias.forEach(m => sel.appendChild(new Option(`${m.nombre} (${m.unidad||'-'})`, m.id_mat_prima, false, m.id_mat_prima===it.id_mat_prima)));
|
||||
sel.addEventListener('change', () => {
|
||||
const val = parseInt(sel.value, 10);
|
||||
if (!Number.isInteger(val) || val <= 0) { // si algo raro
|
||||
const first = state.materias[0];
|
||||
it.id_mat_prima = Number(first?.id_mat_prima || 0);
|
||||
} else {
|
||||
it.id_mat_prima = val;
|
||||
}
|
||||
const m = state.materias.find(x => x.id_mat_prima === it.id_mat_prima);
|
||||
it.nombre = m?.nombre || '';
|
||||
it.unidad = m?.unidad || '';
|
||||
});
|
||||
|
||||
|
||||
const qty = document.createElement('input');
|
||||
qty.type = 'number'; qty.min='0.001'; qty.step='0.001'; qty.value = (it.qty_por_unidad||0).toFixed(3);
|
||||
qty.className = 'form-control form-control-sm text-end';
|
||||
qty.addEventListener('change', ()=> it.qty_por_unidad = Number(qty.value||0));
|
||||
|
||||
const del = document.createElement('button');
|
||||
del.className = 'btn btn-outline-secondary btn-sm';
|
||||
del.textContent = 'Quitar';
|
||||
del.addEventListener('click', ()=> { state.receta.splice(idx,1); renderReceta(); });
|
||||
|
||||
const td1 = document.createElement('td'); td1.appendChild(sel);
|
||||
const td2 = document.createElement('td'); td2.className='text-end'; td2.appendChild(qty);
|
||||
const td3 = document.createElement('td'); td3.appendChild(del);
|
||||
|
||||
tr.append(td1,td2,td3);
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function addIngrediente(){
|
||||
if (!state.materias.length) {
|
||||
toast('Primero cargá materias primas', false);
|
||||
return;
|
||||
}
|
||||
const m = state.materias[0];
|
||||
state.receta.push({
|
||||
id_mat_prima: Number(m.id_mat_prima), // siempre número válido
|
||||
nombre: m.nombre || '',
|
||||
unidad: m.unidad || '',
|
||||
qty_por_unidad: 1.000
|
||||
});
|
||||
renderReceta();
|
||||
}
|
||||
|
||||
// ===== Guardar (INSERT/UPDATE + receta) vía función SQL =====
|
||||
async function guardar(){
|
||||
try {
|
||||
const cleanedReceta = state.receta
|
||||
.map(r => ({
|
||||
id: parseInt(r.id_mat_prima, 10),
|
||||
qty: Number(r.qty_por_unidad)
|
||||
}))
|
||||
.filter(x => Number.isInteger(x.id) && x.id > 0 && Number.isFinite(x.qty) && x.qty > 0)
|
||||
.map(x => ({ id_mat_prima: x.id, qty_por_unidad: +x.qty.toFixed(3) }));
|
||||
|
||||
const payload = {
|
||||
id_producto: $('#id_producto').value ? Number($('#id_producto').value) : null,
|
||||
nombre: $('#nombre').value.trim(),
|
||||
img_producto: $('#img_producto').value.trim() || null,
|
||||
precio: Number($('#precio').value || 0),
|
||||
activo: $('#activo').checked,
|
||||
id_categoria: $('#id_categoria').value ? Number($('#id_categoria').value) : null,
|
||||
receta: cleanedReceta
|
||||
};
|
||||
|
||||
if (cleanedReceta.length !== state.receta.length) {
|
||||
toast('Se ignoraron ingredientes inválidos (id o cantidad).', false);
|
||||
}
|
||||
|
||||
if (!payload.nombre) { toast('Nombre requerido'); return; }
|
||||
if (!(payload.precio >= 0)) { toast('Precio inválido'); return; }
|
||||
|
||||
const { id_producto } = await jpost('/api/rpc/save_producto', payload);
|
||||
toast(`Guardado #${id_producto}`, true);
|
||||
|
||||
// refrescar listado y reabrir seleccionado
|
||||
state.productos = await jget('/api/table/productos?limit=2000');
|
||||
renderLista();
|
||||
await loadProducto(id_producto);
|
||||
} catch (e) {
|
||||
toast(e.message || 'No se pudo guardar');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Eventos =====
|
||||
$('#q').addEventListener('input', ()=> { state.filtro = $('#q').value||''; renderLista(); });
|
||||
$('#btnLimpiar').addEventListener('click', ()=> { $('#q').value=''; state.filtro=''; renderLista(); });
|
||||
$('#btnNuevo').addEventListener('click', clearForm);
|
||||
$('#btnAddIng').addEventListener('click', addIngrediente);
|
||||
$('#btnGuardar').addEventListener('click', guardar);
|
||||
$('#img_producto').addEventListener('input', ()=> {
|
||||
const v=$('#img_producto').value.trim();
|
||||
if (v) { $('#preview').src=v; $('#preview').classList.remove('d-none'); } else { $('#preview').src=''; $('#preview').classList.add('d-none'); }
|
||||
});
|
||||
|
||||
// Hooks con sidebar (opcional)
|
||||
window.scRefreshList = async function(){ state.productos = await jget('/api/table/productos?limit=2000'); renderLista(); };
|
||||
window.scExportCsv = function(){
|
||||
const rows = state.productos.slice();
|
||||
const head = ["id_producto","nombre","precio","activo","id_categoria"];
|
||||
const csv = [head.join(",")].concat(rows.map(r => {
|
||||
const vals = [r.id_producto,r.nombre,(r.precio??''),(r.activo??''),(r.id_categoria??'')];
|
||||
return vals.map(v => `"${String(v??'').replaceAll('"','""')}"`).join(",");
|
||||
})).join("\n");
|
||||
const blob = new Blob([csv],{type:"text/csv;charset=utf-8"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement("a"), {href:url, download:`productos_${new Date().toISOString().slice(0,10)}.csv`});
|
||||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/* ========= EXTENSIÓN: MATERIAS PRIMAS ========= */
|
||||
|
||||
// 1) ampliar el estado global existente:
|
||||
state.proveedores = state.proveedores || [];
|
||||
state.mpFiltro = '';
|
||||
state.mpSelId = null;
|
||||
state.mpAsignados = []; // array de id_proveedor seleccionados para la MP
|
||||
|
||||
// 2) cargar proveedores también en init()
|
||||
const __oldInit = init;
|
||||
init = async function() {
|
||||
const [provs] = await Promise.all([
|
||||
jget('/api/table/proveedores?limit=5000').catch(()=>[])
|
||||
]);
|
||||
state.proveedores = Array.isArray(provs) ? provs : [];
|
||||
await __oldInit(); // llama a tu init original (productos + categorías + materias)
|
||||
hydrateMpProveedoresOptions(); // por si abres el panel de MP
|
||||
};
|
||||
|
||||
// helpers UI MP
|
||||
function mpToast(t, ok=false){ const el=$('#mpMsg'); el.className = ok?'text-success':'text-danger'; el.textContent=t; setTimeout(()=>{el.textContent=''; el.className='';}, 3000); }
|
||||
function hydrateMpProveedoresOptions(selectedIds=[]) {
|
||||
const sel = $('#mp_proveedores'); if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
state.proveedores.forEach(p => {
|
||||
const opt = new Option(p.raz_social || ('#'+p.id_proveedor), p.id_proveedor, false, selectedIds.includes(p.id_proveedor));
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// 3) listado MP
|
||||
function renderMpLista() {
|
||||
const tb = $('#mpTb'); if (!tb) return;
|
||||
let rows = state.materias.slice();
|
||||
const f = (state.mpFiltro||'').trim().toLowerCase();
|
||||
if (f) rows = rows.filter(m => (m.nombre||'').toLowerCase().includes(f) || String(m.id_mat_prima).includes(f));
|
||||
|
||||
if (!rows.length) { tb.innerHTML = '<tr><td colspan="4" class="p-3 text-muted">Sin resultados.</td></tr>'; return; }
|
||||
tb.innerHTML = '';
|
||||
rows.forEach(m => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.cursor='pointer';
|
||||
tr.innerHTML = `
|
||||
<td>${m.id_mat_prima}</td>
|
||||
<td>${m.nombre||'—'}</td>
|
||||
<td>${m.unidad||'—'}</td>
|
||||
<td>${m.activo ? 'Sí' : 'No'}</td>
|
||||
`;
|
||||
tr.addEventListener('click', ()=> loadMp(m.id_mat_prima));
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// 4) limpiar ficha MP
|
||||
function clearMpForm() {
|
||||
state.mpSelId = null;
|
||||
$('#mp_id_mat_prima').value = '';
|
||||
$('#mp_nombre').value = '';
|
||||
$('#mp_unidad').value = '';
|
||||
$('#mp_activo').checked = true;
|
||||
state.mpAsignados = [];
|
||||
hydrateMpProveedoresOptions([]);
|
||||
}
|
||||
|
||||
// 5) cargar MP + proveedores asignados (via función SQL)
|
||||
async function loadMp(id) {
|
||||
try {
|
||||
const data = await jget(`/api/rpc/get_materia/${id}`); // { materia: {...}, proveedores: [...] }
|
||||
const m = data.materia || {};
|
||||
const provs = Array.isArray(data.proveedores) ? data.proveedores : [];
|
||||
|
||||
state.mpSelId = m.id_mat_prima;
|
||||
$('#mp_id_mat_prima').value = m.id_mat_prima ?? '';
|
||||
$('#mp_nombre').value = m.nombre ?? '';
|
||||
$('#mp_unidad').value = m.unidad ?? '';
|
||||
$('#mp_activo').checked = (m.activo !== false);
|
||||
|
||||
state.mpAsignados = provs.map(x => x.id_proveedor);
|
||||
hydrateMpProveedoresOptions(state.mpAsignados);
|
||||
} catch (e) {
|
||||
mpToast(e.message || 'No se pudo cargar la materia prima');
|
||||
}
|
||||
}
|
||||
|
||||
// 6) guardar MP (insert/update) y sincronizar proveedores (JSONB)
|
||||
async function saveMp() {
|
||||
try {
|
||||
const payload = {
|
||||
id_mat_prima: $('#mp_id_mat_prima').value ? Number($('#mp_id_mat_prima').value) : null,
|
||||
nombre: $('#mp_nombre').value.trim(),
|
||||
unidad: $('#mp_unidad').value.trim(),
|
||||
activo: $('#mp_activo').checked,
|
||||
proveedores: Array.from($('#mp_proveedores').selectedOptions).map(o => Number(o.value))
|
||||
};
|
||||
if (!payload.nombre) { mpToast('Nombre requerido'); return; }
|
||||
|
||||
const r = await jpost('/api/rpc/save_materia', payload); // => { id_mat_prima }
|
||||
mpToast(`Guardado #${r.id_mat_prima}`, true);
|
||||
|
||||
// refrescar listas globales
|
||||
state.materias = await jget('/api/table/mate_primas?limit=5000');
|
||||
renderMpLista();
|
||||
hydrateCategorias(); // no hace falta, pero mantenemos consistencia si dependiera de MPs
|
||||
// refrescar selects de receta del producto (por si se usa en receta)
|
||||
renderReceta(); // tu función existente reusará state.materias
|
||||
await loadMp(r.id_mat_prima);
|
||||
} catch (e) {
|
||||
mpToast(e.message || 'No se pudo guardar');
|
||||
}
|
||||
}
|
||||
|
||||
// 7) listeners MP
|
||||
document.getElementById('mpQ')?.addEventListener('input', ()=> { state.mpFiltro = $('#mpQ').value||''; renderMpLista(); });
|
||||
document.getElementById('mpLimpiar')?.addEventListener('click', ()=> { $('#mpQ').value=''; state.mpFiltro=''; renderMpLista(); });
|
||||
document.getElementById('mpNuevo')?.addEventListener('click', clearMpForm);
|
||||
document.getElementById('mpGuardar')?.addEventListener('click', saveMp);
|
||||
|
||||
// 8) cuando se despliega el panel MP por primera vez, renderizar listado
|
||||
document.getElementById('mpWrap')?.addEventListener('shown.bs.collapse', ()=> renderMpLista());
|
||||
|
||||
function imgUrl(v){
|
||||
if (!v) return '';
|
||||
return v.startsWith('http') ? v : `/img/productos/${v}`;
|
||||
}
|
||||
|
||||
$('#img_producto').addEventListener('input', ()=>{
|
||||
const v = $('#img_producto').value.trim();
|
||||
const src = imgUrl(v);
|
||||
if (src) { $('#preview').src = src; $('#preview').classList.remove('d-none'); }
|
||||
else { $('#preview').src = ''; $('#preview').classList.add('d-none'); }
|
||||
});
|
||||
|
||||
// Go
|
||||
init().catch(e => toast(e.message||'Error cargando datos'));
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user