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'
|
maxAge: '1y'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// Configuración de conexión PostgreSQL
|
// Configuración de conexión PostgreSQL
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
@ -228,6 +230,11 @@ app.get("/estadoComandas", (req, res) => {
|
|||||||
// res.sendFile(path.join(__dirname, 'pages', 'estadoComandas.html'));
|
// 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
|
// API
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
@ -513,6 +520,64 @@ app.post('/api/comandas/:id/abrir', async (req, res, next) => {
|
|||||||
// } catch (e) { next(e); }
|
// } 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
|
// 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