362 lines
13 KiB
Plaintext
362 lines
13 KiB
Plaintext
<% /* Compras / Gastos */ %>
|
|
<div class="container-fluid py-3">
|
|
<div class="d-flex align-items-center mb-2">
|
|
<h3 class="mb-0">Compras / Gastos</h3>
|
|
<div class="ms-auto d-flex gap-2">
|
|
<button id="btnNueva" class="btn btn-outline-secondary btn-sm">Nueva</button>
|
|
<span id="status" class="small text-muted">—</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Formulario -->
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header"><strong id="formTitle">Nueva compra</strong></div>
|
|
<div class="card-body">
|
|
<form id="frmCompra" class="row g-3">
|
|
<input type="hidden" id="id_compra" value="">
|
|
<div class="col-12 col-md-5">
|
|
<label class="form-label">Proveedor</label>
|
|
<select id="id_proveedor" class="form-select" required></select>
|
|
</div>
|
|
<div class="col-12 col-md-3">
|
|
<label class="form-label">Fecha</label>
|
|
<input id="fec_compra" type="datetime-local" class="form-control" required>
|
|
</div>
|
|
<div class="col-12 col-md-4">
|
|
<label class="form-label">Total</label>
|
|
<input id="total" type="text" class="form-control" value="$ 0" disabled>
|
|
</div>
|
|
|
|
<div class="col-12">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<div class="fw-semibold">Renglones</div>
|
|
<div>
|
|
<button type="button" id="addRow" class="btn btn-sm btn-outline-primary">Agregar renglón</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle" id="tblDet">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:110px">Tipo</th>
|
|
<th>Ítem</th>
|
|
<th style="width:140px" class="text-end">Cantidad</th>
|
|
<th style="width:160px" class="text-end">Precio</th>
|
|
<th style="width:140px" class="text-end">Subtotal</th>
|
|
<th style="width:60px"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr class="empty">
|
|
<td colspan="6" class="p-3 text-muted">Sin renglones</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 d-flex gap-2">
|
|
<button id="btnGuardar" type="submit" class="btn btn-success">Guardar</button>
|
|
<button id="btnEliminar" type="button" class="btn btn-outline-danger d-none">Eliminar</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Listado -->
|
|
<div class="card shadow-sm">
|
|
<div class="card-header d-flex align-items-center">
|
|
<strong>Compras recientes</strong>
|
|
<input id="buscar" class="form-control form-control-sm ms-auto" style="max-width:260px" placeholder="Buscar proveedor…">
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle mb-0" id="tblCompras">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Proveedor</th>
|
|
<th>Fecha</th>
|
|
<th class="text-end">Total</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td colspan="5" class="p-3 text-muted">Sin datos</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
#tblDet select, #tblDet input { min-height: 34px; }
|
|
.money { text-align: right; }
|
|
</style>
|
|
|
|
<script>
|
|
const $ = s => document.querySelector(s);
|
|
const $$ = (s, root=document) => Array.from(root.querySelectorAll(s));
|
|
const z2 = n => String(n).padStart(2,'0');
|
|
const parseNum = v => (typeof v==='number') ? v : Number(String(v).replace(/[^\d.,-]/g,'').replace('.','').replace(',','.')) || 0;
|
|
|
|
function fmtMoneyInt(v, mode = 'round') {
|
|
const n = Number(v || 0);
|
|
const i = mode === 'trunc' ? Math.trunc(n) : mode === 'floor' ? Math.floor(n) : Math.round(n);
|
|
return '$ ' + i.toLocaleString('es-UY', { maximumFractionDigits: 0 });
|
|
}
|
|
|
|
const onlyDigits = s => String(s ?? '').replace(/\D+/g, '');
|
|
|
|
function wireIntInput(input, onChange) {
|
|
const sync = () => {
|
|
const n = Number(onlyDigits(input.value) || '0'); // entero
|
|
input.dataset.raw = String(n); // guardo valor crudo
|
|
input.value = n.toLocaleString('es-UY'); // muestro con miles
|
|
if (onChange) onChange(n);
|
|
};
|
|
input.addEventListener('input', () => setTimeout(sync, 0));
|
|
input.addEventListener('blur', sync);
|
|
// 1a sync
|
|
sync();
|
|
}
|
|
|
|
|
|
|
|
|
|
function getIntInput(input) {
|
|
const s = input?.dataset?.raw ?? onlyDigits(input?.value);
|
|
return Number(s || '0');
|
|
}
|
|
|
|
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; }
|
|
|
|
// Catálogos
|
|
let CATS = { prov:[], mat:[], prod:[] };
|
|
|
|
async function loadCatalogos(){
|
|
$('#status').textContent = 'Cargando catálogos…';
|
|
const [prov, mat, prod] = await Promise.all([
|
|
jget('/api/table/proveedores?limit=10000'),
|
|
jget('/api/table/mate_primas?limit=10000'),
|
|
jget('/api/table/productos?limit=10000')
|
|
]);
|
|
CATS.prov = prov||[]; CATS.mat = mat||[]; CATS.prod = prod||[];
|
|
const sel = $('#id_proveedor'); sel.innerHTML = '<option value="">—</option>' + CATS.prov.map(p=>`<option value="${p.id_proveedor}">${p.raz_social||p.nombre||('Prov#'+p.id_proveedor)}</option>`).join('');
|
|
$('#status').textContent = 'Listo';
|
|
}
|
|
|
|
// Renglón
|
|
function addRow(data){
|
|
const tb = $('#tblDet tbody');
|
|
tb.querySelector('.empty')?.remove();
|
|
|
|
const tr = document.createElement('tr');
|
|
const tipo = data?.tipo || 'MAT'; // MAT | PROD
|
|
const id = data?.id || '';
|
|
const cant = data?.cantidad != null ? data.cantidad : 1;
|
|
const pu = data?.precio != null ? data.precio : 0;
|
|
|
|
tr.innerHTML = `
|
|
<td>
|
|
<select class="form-select form-select-sm tipo">
|
|
<option value="MAT"${tipo==='MAT'?' selected':''}>Materia</option>
|
|
<option value="PROD"${tipo==='PROD'?' selected':''}>Producto</option>
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<select class="form-select form-select-sm item"></select>
|
|
</td>
|
|
<td><input type="text" inputmode="numeric" pattern="[0-9]*"
|
|
class="form-control form-control-sm text-end qty" value="${cant}"></td>
|
|
<td><input type="text" inputmode="numeric" pattern="[0-9]*"
|
|
class="form-control form-control-sm text-end price" value="${pu}"></td>
|
|
<td class="text-end sub">$ 0</td>
|
|
<td><button type="button" class="btn btn-sm btn-outline-danger del">✕</button></td>
|
|
`;
|
|
tb.appendChild(tr);
|
|
|
|
// load items segun tipo
|
|
function fillItems(selTipo, selItem, selectedId){
|
|
const list = selTipo.value === 'MAT' ? CATS.mat : CATS.prod;
|
|
selItem.innerHTML = '<option value="">—</option>' + list.map(i => {
|
|
const id = selTipo.value === 'MAT' ? i.id_mat_prima : i.id_producto;
|
|
const nm = i.nombre || ('#'+id);
|
|
return `<option value="${id}">${nm}</option>`;
|
|
}).join('');
|
|
if (selectedId) selItem.value = selectedId;
|
|
}
|
|
|
|
const selTipo = tr.querySelector('.tipo');
|
|
const selItem = tr.querySelector('.item');
|
|
const qty = tr.querySelector('.qty');
|
|
const price = tr.querySelector('.price');
|
|
const subCell = tr.querySelector('.sub');
|
|
|
|
selTipo.addEventListener('change', ()=>{ fillItems(selTipo, selItem, null); updateRow(); });
|
|
[selItem, qty, price].forEach(el => el.addEventListener('input', updateRow));
|
|
tr.querySelector('.del').addEventListener('click', ()=>{ tr.remove(); recalcTotal(); if (!tb.children.length) tb.innerHTML='<tr class="empty"><td colspan="6" class="p-3 text-muted">Sin renglones</td></tr>'; });
|
|
|
|
fillItems(selTipo, selItem, id);
|
|
function updateRow(){
|
|
const s = getIntInput(qty) * getIntInput(price);
|
|
subCell.textContent = fmtMoneyInt(s);
|
|
recalcTotal();
|
|
}
|
|
wireIntInput(qty, updateRow);
|
|
wireIntInput(price, updateRow);
|
|
updateRow();
|
|
}
|
|
|
|
function recalcTotal(){
|
|
let tot = 0;
|
|
$('#tblDet tbody').querySelectorAll('tr').forEach(tr=>{
|
|
if (tr.classList.contains('empty')) return;
|
|
const q = getIntInput(tr.querySelector('.qty'));
|
|
const p = getIntInput(tr.querySelector('.price'));
|
|
tot += q * p;
|
|
});
|
|
$('#total').value = fmtMoneyInt(tot);
|
|
return tot;
|
|
}
|
|
|
|
function readFormToPayload(){
|
|
const id_compra = $('#id_compra').value ? Number($('#id_compra').value) : null;
|
|
const id_proveedor = Number($('#id_proveedor').value || 0);
|
|
const fec_compra = $('#fec_compra').value
|
|
? new Date($('#fec_compra').value).toISOString().slice(0,19).replace('T',' ')
|
|
: null;
|
|
|
|
const det = [];
|
|
// 👇 OJO: iteramos sobre TODAS las filas reales
|
|
$('#tblDet tbody').querySelectorAll('tr').forEach(tr=>{
|
|
if (tr.classList.contains('empty')) return;
|
|
const tipo = tr.querySelector('.tipo').value; // 'MAT' | 'PROD'
|
|
const id = Number(tr.querySelector('.item').value||0);
|
|
const qty = getIntInput(tr.querySelector('.qty')); // entero
|
|
const pu = getIntInput(tr.querySelector('.price')); // entero
|
|
if (id && qty>0 && pu>=0) det.push({ tipo, id, cantidad: qty, precio: pu });
|
|
});
|
|
|
|
return { id_compra, id_proveedor, fec_compra, detalles: det };
|
|
}
|
|
|
|
// Guardar / Eliminar
|
|
async function saveCompra(){
|
|
const payload = readFormToPayload();
|
|
if (!payload.id_proveedor) { alert('Seleccioná un proveedor.'); return; }
|
|
if (!payload.fec_compra) { alert('Indicá la fecha.'); return; }
|
|
if (!payload.detalles.length){ alert('Agregá al menos un renglón.'); return; }
|
|
|
|
$('#btnGuardar').disabled = true; $('#status').textContent = 'Guardando…';
|
|
try{
|
|
const res = await jpost('/api/rpc/save_compra', payload);
|
|
$('#id_compra').value = res.id_compra;
|
|
$('#btnEliminar').classList.remove('d-none');
|
|
$('#formTitle').textContent = 'Editar compra #' + res.id_compra;
|
|
await loadListado();
|
|
alert('Compra guardada.');
|
|
}catch(e){
|
|
alert('Error al guardar: ' + e.message);
|
|
}finally{
|
|
$('#btnGuardar').disabled = false; $('#status').textContent = 'Listo';
|
|
}
|
|
}
|
|
|
|
async function deleteCompra(){
|
|
const id = Number($('#id_compra').value||0);
|
|
if (!id) return;
|
|
if (!confirm('¿Eliminar compra #' + id + '?')) return;
|
|
$('#btnEliminar').disabled = true;
|
|
try{
|
|
await jpost('/api/rpc/delete_compra', { id_compra: id });
|
|
nuevaCompra();
|
|
await loadListado();
|
|
}catch(e){
|
|
alert('Error al eliminar: '+e.message);
|
|
}finally{
|
|
$('#btnEliminar').disabled = false;
|
|
}
|
|
}
|
|
|
|
function nuevaCompra(){
|
|
$('#formTitle').textContent = 'Nueva compra';
|
|
$('#id_compra').value = '';
|
|
$('#id_proveedor').value = '';
|
|
$('#fec_compra').value = new Date().toISOString().slice(0,16);
|
|
$('#total').value = '$ 0';
|
|
$('#btnEliminar').classList.add('d-none');
|
|
const tb = $('#tblDet tbody'); tb.innerHTML = '<tr class="empty"><td colspan="6" class="p-3 text-muted">Sin renglones</td></tr>';
|
|
}
|
|
|
|
async function cargarCompra(id){
|
|
$('#status').textContent = 'Cargando compra…';
|
|
try{
|
|
const data = await jpost('/api/rpc/get_compra', { id_compra: id });
|
|
$('#id_compra').value = data.id_compra;
|
|
$('#id_proveedor').value = data.id_proveedor;
|
|
$('#fec_compra').value = (data.fec_compra || '').replace(' ', 'T').slice(0,16);
|
|
const tb = $('#tblDet tbody'); tb.innerHTML='';
|
|
(data.detalles||[]).forEach(d => addRow(d));
|
|
recalcTotal();
|
|
$('#btnEliminar').classList.remove('d-none');
|
|
$('#formTitle').textContent = 'Editar compra #' + id;
|
|
} catch(e){
|
|
alert('No se pudo cargar: ' + e.message);
|
|
} finally {
|
|
$('#status').textContent = 'Listo';
|
|
}
|
|
}
|
|
|
|
// Listado
|
|
async function loadListado(){
|
|
// Recomendado: vista vw_compras (más abajo)
|
|
const rows = await jget('/api/table/vw_compras?limit=200&order_by=fec_compra%20desc');
|
|
const tb = $('#tblCompras tbody');
|
|
if (!rows?.length){ tb.innerHTML = '<tr><td colspan="5" class="p-3 text-muted">Sin datos</td></tr>'; return; }
|
|
tb.innerHTML = '';
|
|
rows.forEach(r=>{
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td>${r.id_compra}</td>
|
|
<td>${r.proveedor}</td>
|
|
<td>${(r.fec_compra||'').replace('T',' ').slice(0,16)}</td>
|
|
<td class="text-end">${fmtMoneyInt(r.total)}</td>
|
|
<td class="text-end"><button class="btn btn-sm btn-outline-primary ver" data-id="${r.id_compra}">Ver/Editar</button></td>`;
|
|
tb.appendChild(tr);
|
|
});
|
|
|
|
$('#buscar').addEventListener('input', (e)=>{
|
|
const q = e.target.value.toLowerCase();
|
|
tb.querySelectorAll('tr').forEach(tr=>{
|
|
const prov = tr.children[1]?.textContent.toLowerCase() || '';
|
|
tr.style.display = prov.includes(q) ? '' : 'none';
|
|
});
|
|
});
|
|
|
|
tb.addEventListener('click', (ev)=>{
|
|
const btn = ev.target.closest('button.ver');
|
|
if (!btn) return;
|
|
const id = Number(btn.dataset.id);
|
|
cargarCompra(id);
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
});
|
|
}
|
|
|
|
// Eventos
|
|
document.getElementById('addRow').addEventListener('click', ()=> addRow());
|
|
document.getElementById('frmCompra').addEventListener('submit', (ev)=>{ ev.preventDefault(); saveCompra(); });
|
|
document.getElementById('btnEliminar').addEventListener('click', deleteCompra);
|
|
document.getElementById('btnNueva').addEventListener('click', nuevaCompra);
|
|
|
|
// Init
|
|
(async function init(){
|
|
await loadCatalogos();
|
|
nuevaCompra();
|
|
await loadListado();
|
|
})();
|
|
</script>
|