Modificación o agregado de productos y materias primas

This commit is contained in:
Mateo Saldain 2025-08-29 14:22:30 +00:00
parent ce3d01a180
commit 9c5219863b
3 changed files with 624 additions and 0 deletions

View File

@ -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
// ---------------------------------------------------------- // ----------------------------------------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

View 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>