Re estructuración de patrones de diseño con el código de Manso
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Comandas</title>
|
||||
<style>
|
||||
:root { --gap: 12px; --radius: 10px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
|
||||
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
|
||||
header h1 { margin:0; font-size:16px; font-weight:600; }
|
||||
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 1.15fr 0.85fr; gap: var(--gap); }
|
||||
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
|
||||
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
|
||||
.card .bd { padding:14px; }
|
||||
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
|
||||
.grid { display:grid; gap:10px; }
|
||||
.grid.cols-2 { grid-template-columns: 1fr 1fr; }
|
||||
.muted { color:#666; }
|
||||
select, input, textarea, button { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
|
||||
select:focus, input:focus, textarea:focus { outline:none; border-color:#999; }
|
||||
input[type="number"] { width: 100%; }
|
||||
textarea { width:100%; min-height: 68px; resize: vertical; }
|
||||
button { cursor: pointer; }
|
||||
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; }
|
||||
.btn.primary { background:#111; border-color:#111; color:#fff; }
|
||||
.btn.ghost { background:#fff; }
|
||||
.btn.small { padding:6px 8px; font-size: 13px; }
|
||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; }
|
||||
.spacer { flex:1 }
|
||||
.search { display:flex; gap:8px; }
|
||||
.search input { flex:1; }
|
||||
table { width:100%; border-collapse: collapse; }
|
||||
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||
th, td { padding:8px 10px; border-bottom:1px solid #eee; vertical-align: middle; }
|
||||
.qty { display:flex; align-items:center; gap:6px; }
|
||||
.qty input { width: 90px; }
|
||||
.right { text-align:right; }
|
||||
.total { font-size: 22px; font-weight: 700; }
|
||||
.notice { padding:10px; border-radius:8px; border:1px solid #e7e7ef; background:#fafafa; }
|
||||
.ok { color:#0a7d28; }
|
||||
.err { color:#b00020; }
|
||||
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
|
||||
.kpi { display:flex; gap:6px; align-items: baseline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>📋 Nueva Comanda</h1>
|
||||
<div class="spacer"></div>
|
||||
<span class="pill muted">/api/*</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Panel izquierdo: productos -->
|
||||
<section class="card" id="panelProductos">
|
||||
<div class="hd">
|
||||
<strong>Productos</strong>
|
||||
<div class="spacer"></div>
|
||||
<div class="toolbar">
|
||||
<span class="muted" id="prodCount">0 ítems</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bd">
|
||||
<div class="row search" style="margin-bottom:10px;">
|
||||
<input id="busqueda" type="search" placeholder="Buscar por nombre o categoría…"/>
|
||||
<button class="btn" id="limpiarBusqueda">Limpiar</button>
|
||||
</div>
|
||||
<div id="listadoProductos" style="max-height: 58vh; overflow:auto;">
|
||||
<!-- tabla productos -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Panel derecho: datos + carrito + crear -->
|
||||
<section class="card" id="panelComanda">
|
||||
<div class="hd"><strong>Detalles</strong></div>
|
||||
<div class="bd grid" style="gap:14px;">
|
||||
<div class="grid cols-2">
|
||||
<div>
|
||||
<label class="muted">Mesa</label>
|
||||
<select id="selMesa"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="muted">Usuario</label>
|
||||
<select id="selUsuario"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="muted">Observaciones</label>
|
||||
<textarea id="obs"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="notice muted">La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="hd"><strong>Carrito</strong></div>
|
||||
<div class="bd" id="carritoWrap">
|
||||
<div class="muted">Aún no agregaste productos.</div>
|
||||
</div>
|
||||
<div class="sticky-footer">
|
||||
<div class="kpi"><span class="muted">Ítems:</span><strong id="kpiItems">0</strong></div>
|
||||
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn ghost" id="vaciar">Vaciar</button>
|
||||
<button class="btn primary" id="crear">Crear Comanda</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="msg" class="muted"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
|
||||
const state = {
|
||||
productos: [],
|
||||
mesas: [],
|
||||
usuarios: [],
|
||||
carrito: [], // [{id_producto, nombre, pre_unitario, cantidad}]
|
||||
filtro: ''
|
||||
};
|
||||
|
||||
// ---------- Utils ----------
|
||||
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
||||
const toast = (msg, ok=false) => { const el = $('#msg'); el.className = ok ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
|
||||
async function jget(url) {
|
||||
const res = await fetch(url);
|
||||
let data; try { data = await res.json(); } catch { data = null; }
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
async function jpost(url, body) {
|
||||
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- Load data ----------
|
||||
async function init() {
|
||||
// productos, mesas, usuarios
|
||||
const [prods, mesas, usuarios] = await Promise.all([
|
||||
jget('/api/table/productos?limit=1000'),
|
||||
jget('/api/table/mesas?limit=1000'),
|
||||
jget('/api/table/usuarios?limit=1000')
|
||||
]);
|
||||
|
||||
state.productos = prods.filter(p => p.activo !== false); // si existe activo=false, filtrarlo
|
||||
state.mesas = mesas;
|
||||
state.usuarios = usuarios.filter(u => u.activo !== false);
|
||||
|
||||
hydrateMesas();
|
||||
hydrateUsuarios();
|
||||
renderProductos();
|
||||
renderCarrito();
|
||||
|
||||
$('#busqueda').addEventListener('input', () => { state.filtro = $('#busqueda').value.trim().toLowerCase(); renderProductos(); });
|
||||
$('#limpiarBusqueda').addEventListener('click', () => { $('#busqueda').value=''; state.filtro=''; renderProductos(); });
|
||||
$('#vaciar').addEventListener('click', () => { state.carrito=[]; renderCarrito(); });
|
||||
$('#crear').addEventListener('click', crearComanda);
|
||||
}
|
||||
|
||||
function hydrateMesas() {
|
||||
const sel = $('#selMesa'); sel.innerHTML = '';
|
||||
for (const m of state.mesas) {
|
||||
const o = document.createElement('option');
|
||||
o.value = m.id_mesa;
|
||||
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateUsuarios() {
|
||||
const sel = $('#selUsuario'); sel.innerHTML = '';
|
||||
for (const u of state.usuarios) {
|
||||
const o = document.createElement('option');
|
||||
o.value = u.id_usuario;
|
||||
o.textContent = `${u.nombre} ${u.apellido}`.trim();
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Productos ----------
|
||||
function renderProductos() {
|
||||
let rows = state.productos.slice();
|
||||
if (state.filtro) {
|
||||
rows = rows.filter(p =>
|
||||
(p.nombre || '').toLowerCase().includes(state.filtro) ||
|
||||
String(p.id_categoria ?? '').includes(state.filtro)
|
||||
);
|
||||
}
|
||||
$('#prodCount').textContent = `${rows.length} ítems`;
|
||||
|
||||
if (!rows.length) {
|
||||
$('#listadoProductos').innerHTML = '<div class="muted">Sin resultados.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="right">Precio</th>
|
||||
<th style="width:180px;">Cantidad</th>
|
||||
<th style="width:90px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
for (const p of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${p.nombre}</td>
|
||||
<td class="right">${money(p.precio)}</td>
|
||||
<td>
|
||||
<div class="qty">
|
||||
<input type="number" min="0.001" step="0.001" value="1.000" data-qty />
|
||||
<button class="btn small" data-dec>-</button>
|
||||
<button class="btn small" data-inc>+</button>
|
||||
</div>
|
||||
</td>
|
||||
<td><button class="btn primary small" data-add>Agregar</button></td>
|
||||
`;
|
||||
const qty = tr.querySelector('[data-qty]');
|
||||
tr.querySelector('[data-dec]').addEventListener('click', () => { qty.value = Math.max(0.001, (parseFloat(qty.value||'0') - 1)).toFixed(3); });
|
||||
tr.querySelector('[data-inc]').addEventListener('click', () => { qty.value = (parseFloat(qty.value||'0') + 1).toFixed(3); });
|
||||
tr.querySelector('[data-add]').addEventListener('click', () => addToCart(p, parseFloat(qty.value||'1')) );
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
|
||||
$('#listadoProductos').innerHTML = '';
|
||||
$('#listadoProductos').appendChild(tbl);
|
||||
}
|
||||
|
||||
function addToCart(prod, cantidad) {
|
||||
if (!(cantidad > 0)) { toast('Cantidad inválida'); return; }
|
||||
const precio = parseFloat(prod.precio);
|
||||
const it = state.carrito.find(i => i.id_producto === prod.id_producto && i.pre_unitario === precio);
|
||||
if (it) it.cantidad = Number((it.cantidad + cantidad).toFixed(3));
|
||||
else state.carrito.push({ id_producto: prod.id_producto, nombre: prod.nombre, pre_unitario: precio, cantidad: Number(cantidad.toFixed(3)) });
|
||||
renderCarrito();
|
||||
}
|
||||
|
||||
// ---------- Carrito ----------
|
||||
function renderCarrito() {
|
||||
const wrap = $('#carritoWrap');
|
||||
if (!state.carrito.length) { wrap.innerHTML = '<div class="muted">Aún no agregaste productos.</div>'; $('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0); return; }
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="right">Unitario</th>
|
||||
<th class="right">Cantidad</th>
|
||||
<th class="right">Subtotal</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
let items = 0, total = 0;
|
||||
state.carrito.forEach((it, idx) => {
|
||||
items += 1;
|
||||
const sub = Number(it.pre_unitario) * Number(it.cantidad);
|
||||
total += sub;
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${it.nombre}</td>
|
||||
<td class="right">${money(it.pre_unitario)}</td>
|
||||
<td class="right">
|
||||
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" style="width:120px"/>
|
||||
</td>
|
||||
<td class="right">${money(sub)}</td>
|
||||
<td class="right">
|
||||
<button class="btn small" data-del>Quitar</button>
|
||||
</td>
|
||||
`;
|
||||
const qty = tr.querySelector('input[type="number"]');
|
||||
qty.addEventListener('change', () => {
|
||||
const v = parseFloat(qty.value||'0');
|
||||
if (!(v>0)) { toast('Cantidad inválida'); qty.value = it.cantidad.toFixed(3); return; }
|
||||
it.cantidad = Number(v.toFixed(3));
|
||||
renderCarrito();
|
||||
});
|
||||
tr.querySelector('[data-del]').addEventListener('click', () => {
|
||||
state.carrito.splice(idx,1);
|
||||
renderCarrito();
|
||||
});
|
||||
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
wrap.innerHTML = '';
|
||||
wrap.appendChild(tbl);
|
||||
$('#kpiItems').textContent = String(items);
|
||||
$('#kpiTotal').textContent = money(total);
|
||||
}
|
||||
|
||||
// ---------- Crear comanda ----------
|
||||
async function crearComanda() {
|
||||
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
|
||||
const id_mesa = parseInt($('#selMesa').value, 10);
|
||||
const id_usuario = parseInt($('#selUsuario').value, 10);
|
||||
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
|
||||
|
||||
const observaciones = $('#obs').value.trim() || null;
|
||||
|
||||
try {
|
||||
// 1) encabezado comanda (estado por defecto: 'abierta'; fecha la pone la DB)
|
||||
const { inserted: com } = await jpost('/api/table/comandas', {
|
||||
id_usuario,
|
||||
id_mesa,
|
||||
estado: 'abierta',
|
||||
observaciones
|
||||
});
|
||||
|
||||
// 2) detalle (una inserción por renglón)
|
||||
const id_comanda = com.id_comanda;
|
||||
const payloads = state.carrito.map(it => ({
|
||||
id_comanda,
|
||||
id_producto: it.id_producto,
|
||||
cantidad: it.cantidad,
|
||||
pre_unitario: it.pre_unitario
|
||||
}));
|
||||
|
||||
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
|
||||
|
||||
state.carrito = [];
|
||||
renderCarrito();
|
||||
$('#obs').value = '';
|
||||
toast(`Comanda #${id_comanda} creada`, true);
|
||||
} catch (e) {
|
||||
toast(e.message || 'No se pudo crear la comanda');
|
||||
}
|
||||
}
|
||||
|
||||
// GO
|
||||
init().catch(err => toast(err.message || 'Error cargando datos'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,293 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Dashboard</title>
|
||||
<style>
|
||||
:root { --radius: 10px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji'; background:#f6f7fb; color:#111; }
|
||||
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e8e8ef; padding:16px 20px; display:flex; gap:12px; align-items:center; z-index:1;}
|
||||
header h1 { margin:0; font-size:18px; font-weight:600;}
|
||||
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.card { background:#fff; border:1px solid #e8e8ef; border-radius: var(--radius); padding:16px; }
|
||||
.row { display:flex; gap:16px; align-items: center; flex-wrap:wrap; }
|
||||
select, input, button, textarea { font: inherit; padding:10px; border-radius:8px; border:1px solid #d7d7e0; background:#fff; }
|
||||
select:focus, input:focus, textarea:focus { outline: none; border-color:#888; }
|
||||
button { cursor:pointer; }
|
||||
button.primary { background:#111; color:#fff; border-color:#111; }
|
||||
table { width:100%; border-collapse: collapse; }
|
||||
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||
th, td { padding:10px 12px; border-bottom: 1px solid #eee; vertical-align: top; }
|
||||
.muted { color:#666; }
|
||||
.tabs { display:flex; gap:6px; margin-top:12px; }
|
||||
.tab { padding:8px 10px; border:1px solid #e0e0ea; border-bottom:none; background:#fafafa; border-top-left-radius:8px; border-top-right-radius:8px; cursor:pointer; font-size:14px; }
|
||||
.tab.active { background:#fff; border-color:#e0e0ea; }
|
||||
.panel { border:1px solid #e0e0ea; border-radius: 0 8px 8px 8px; padding:16px; background:#fff; }
|
||||
.grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
|
||||
.help { font-size:12px; color:#777; margin-top:6px; }
|
||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; }
|
||||
.spacer { flex:1 }
|
||||
.error { color:#b00020; }
|
||||
.success { color:#0a7d28; }
|
||||
.sr-only{ position:absolute; width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
|
||||
details summary { cursor:pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Dashboard</h1>
|
||||
<div class="spacer"></div>
|
||||
<span class="pill muted">/api/*</span>
|
||||
</header>
|
||||
|
||||
<main class="card">
|
||||
<div class="row" style="margin-bottom:12px;">
|
||||
<label for="tableSelect" class="sr-only">Tabla</label>
|
||||
<select id="tableSelect"></select>
|
||||
<div class="spacer"></div>
|
||||
<div class="toolbar">
|
||||
<button id="refreshBtn">Recargar</button>
|
||||
<span id="status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="datos">Datos</button>
|
||||
<button class="tab" data-tab="nuevo">Nuevo</button>
|
||||
<button class="tab" data-tab="esquema">Esquema</button>
|
||||
</div>
|
||||
<section class="panel" id="panel-datos">
|
||||
<div class="help">Mostrando hasta <span id="limitInfo">100</span> filas.</div>
|
||||
<div id="tableContainer" style="overflow:auto;"></div>
|
||||
</section>
|
||||
<section class="panel" id="panel-nuevo" hidden>
|
||||
<form id="insertForm" class="grid"></form>
|
||||
<div class="row" style="margin-top:10px;">
|
||||
<div class="spacer"></div>
|
||||
<button id="insertBtn" class="primary">Insertar</button>
|
||||
</div>
|
||||
<div id="insertMsg" class="help"></div>
|
||||
</section>
|
||||
<section class="panel" id="panel-esquema" hidden>
|
||||
<pre id="schemaPre" style="white-space:pre-wrap;"></pre>
|
||||
</section>
|
||||
|
||||
<details style="margin-top:16px;">
|
||||
<summary>Endpoints</summary>
|
||||
<div class="help">GET /api/tables • GET /api/schema/:tabla • GET /api/table/:tabla?limit=100 • POST /api/table/:tabla</div>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
const state = { tables: [], table: null, schema: null, limit: 100 };
|
||||
|
||||
// Tabs
|
||||
$$('.tab').forEach(t => t.addEventListener('click', () => {
|
||||
$$('.tab').forEach(x => x.classList.remove('active'));
|
||||
t.classList.add('active');
|
||||
const tab = t.dataset.tab;
|
||||
$('#panel-datos').hidden = tab !== 'datos';
|
||||
$('#panel-nuevo').hidden = tab !== 'nuevo';
|
||||
$('#panel-esquema').hidden = tab !== 'esquema';
|
||||
}));
|
||||
|
||||
$('#refreshBtn').addEventListener('click', () => {
|
||||
if (state.table) {
|
||||
loadSchema(state.table);
|
||||
loadData(state.table);
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
setStatus('Cargando tablas…');
|
||||
const res = await fetch('/api/tables');
|
||||
const tables = await res.json();
|
||||
state.tables = tables;
|
||||
const sel = $('#tableSelect');
|
||||
sel.innerHTML = '';
|
||||
tables.forEach(name => {
|
||||
const o = document.createElement('option');
|
||||
o.value = name; o.textContent = name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.addEventListener('change', () => selectTable(sel.value));
|
||||
if (tables.length) {
|
||||
selectTable(tables[0]);
|
||||
} else {
|
||||
setStatus('No hay tablas disponibles.');
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTable(tbl) {
|
||||
state.table = tbl;
|
||||
await loadSchema(tbl);
|
||||
await loadData(tbl);
|
||||
buildForm();
|
||||
}
|
||||
|
||||
async function loadSchema(tbl) {
|
||||
const res = await fetch(`/api/schema/${tbl}`);
|
||||
state.schema = await res.json();
|
||||
$('#schemaPre').textContent = JSON.stringify(state.schema, null, 2);
|
||||
}
|
||||
|
||||
async function loadData(tbl) {
|
||||
setStatus('Cargando datos…');
|
||||
const res = await fetch(`/api/table/${tbl}?limit=${state.limit}`);
|
||||
const data = await res.json();
|
||||
$('#limitInfo').textContent = String(state.limit);
|
||||
renderTable(data);
|
||||
clearStatus();
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
const c = $('#tableContainer');
|
||||
c.innerHTML = '';
|
||||
if (!rows.length) { c.innerHTML = '<div class="muted">Sin datos.</div>'; return; }
|
||||
const headers = Object.keys(rows[0]);
|
||||
const table = document.createElement('table');
|
||||
table.innerHTML = `
|
||||
<thead><tr>${headers.map(h => '<th>'+h+'</th>').join('')}</tr></thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tbody = table.querySelector('tbody');
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = headers.map(h => '<td>'+formatCell(row[h])+'</td>').join('');
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
c.appendChild(table);
|
||||
}
|
||||
|
||||
function formatCell(v) {
|
||||
if (v === null || v === undefined) return '<span class="muted">NULL</span>';
|
||||
if (typeof v === 'boolean') return v ? '✓' : '—';
|
||||
if (typeof v === 'string' && v.match(/^\\d{4}-\\d{2}-\\d{2}/)) return new Date(v).toLocaleString();
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function buildForm() {
|
||||
const form = $('#insertForm');
|
||||
form.innerHTML = '';
|
||||
if (!state.schema) return;
|
||||
for (const col of state.schema.columns) {
|
||||
// Omitir PK auto y columnas generadas
|
||||
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
const id = 'f_'+col.column_name;
|
||||
wrap.innerHTML = `
|
||||
<label for="${id}" class="muted">${col.column_name} <span class="muted">${col.data_type}</span> ${col.is_nullable ? '' : '<span class="pill">requerido</span>'}</label>
|
||||
<div data-input></div>
|
||||
${col.column_default ? '<div class="help">DEFAULT: '+col.column_default+'</div>' : ''}
|
||||
`;
|
||||
const holder = wrap.querySelector('[data-input]');
|
||||
|
||||
if (col.foreign) {
|
||||
const sel = document.createElement('select');
|
||||
sel.id = id;
|
||||
holder.appendChild(sel);
|
||||
hydrateOptions(sel, state.schema.table, col.column_name);
|
||||
} else if (col.data_type.includes('boolean')) {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'checkbox';
|
||||
holder.appendChild(inp);
|
||||
} else if (col.data_type.includes('timestamp')) {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'datetime-local'; inp.required = !col.is_nullable && !col.column_default;
|
||||
holder.appendChild(inp);
|
||||
} else if (col.data_type.includes('date')) {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'date'; inp.required = !col.is_nullable && !col.column_default;
|
||||
holder.appendChild(inp);
|
||||
} else if (col.data_type.match(/numeric|integer|real|double/)) {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'number'; inp.step = 'any'; inp.required = !col.is_nullable && !col.column_default;
|
||||
holder.appendChild(inp);
|
||||
} else if (col.data_type.includes('text') || col.data_type.includes('character')) {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
|
||||
holder.appendChild(inp);
|
||||
} else {
|
||||
const inp = document.createElement('input');
|
||||
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
|
||||
holder.appendChild(inp);
|
||||
}
|
||||
form.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateOptions(selectEl, table, column) {
|
||||
selectEl.innerHTML = '<option value="">Cargando…</option>';
|
||||
const res = await fetch(`/api/options/${table}/${column}`);
|
||||
const opts = await res.json();
|
||||
selectEl.innerHTML = '<option value="">Seleccione…</option>' + opts.map(o => `<option value="${o.id}">${o.label}</option>`).join('');
|
||||
}
|
||||
|
||||
$('#insertBtn').addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!state.table) return;
|
||||
const payload = {};
|
||||
for (const col of state.schema.columns) {
|
||||
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
|
||||
const id = 'f_'+col.column_name;
|
||||
const el = document.getElementById(id);
|
||||
if (!el) continue;
|
||||
|
||||
let val = null;
|
||||
if (el.type === 'checkbox') {
|
||||
val = el.checked;
|
||||
} else if (el.type === 'datetime-local' && el.value) {
|
||||
// Convertir a ISO
|
||||
val = new Date(el.value).toISOString().slice(0,19).replace('T',' ');
|
||||
} else if (el.tagName === 'SELECT') {
|
||||
val = el.value ? (isNaN(el.value) ? el.value : Number(el.value)) : null;
|
||||
} else if (el.type === 'number') {
|
||||
val = el.value === '' ? null : Number(el.value);
|
||||
} else {
|
||||
val = el.value === '' ? null : el.value;
|
||||
}
|
||||
|
||||
if (val === null && !col.is_nullable && !col.column_default) {
|
||||
showInsertMsg('Completa: '+col.column_name, true);
|
||||
return;
|
||||
}
|
||||
if (val !== null) payload[col.column_name] = val;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/table/${state.table}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Error');
|
||||
showInsertMsg('Insertado correctamente (id: '+(data.inserted?.id || '?')+')', false);
|
||||
// Reset form
|
||||
$('#insertForm').reset?.();
|
||||
await loadData(state.table);
|
||||
} catch (e) {
|
||||
showInsertMsg(e.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
function showInsertMsg(msg, isError=false) {
|
||||
const m = $('#insertMsg');
|
||||
m.className = 'help ' + (isError ? 'error' : 'success');
|
||||
m.textContent = msg;
|
||||
}
|
||||
|
||||
function setStatus(text) { $('#status').textContent = text; }
|
||||
function clearStatus() { setStatus(''); }
|
||||
|
||||
// Start
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,280 @@
|
||||
<!-- pages/estadoComandas.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Estado de Comandas</title>
|
||||
<style>
|
||||
:root { --gap: 12px; --radius: 10px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
|
||||
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
|
||||
header h1 { margin:0; font-size:16px; font-weight:600; }
|
||||
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 0.9fr 1.1fr; gap: var(--gap); }
|
||||
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
|
||||
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
|
||||
.card .bd { padding:14px; }
|
||||
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
|
||||
.grid { display:grid; gap:10px; }
|
||||
.muted { color:#666; }
|
||||
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
|
||||
.list { max-height: 70vh; overflow:auto; }
|
||||
.list table { width:100%; border-collapse: collapse; }
|
||||
.list th, .list td { padding:8px 10px; border-bottom:1px solid #eee; }
|
||||
.list thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||
.right { text-align:right; }
|
||||
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; cursor:pointer; }
|
||||
.btn.primary { background:#111; color:#fff; border-color:#111; }
|
||||
.btn.danger { background:#b00020; color:#fff; border-color:#b00020; }
|
||||
.btn.small { font-size: 13px; padding:6px 8px; }
|
||||
select, input, textarea { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
|
||||
.kpi { display:flex; gap:6px; align-items: baseline; }
|
||||
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
|
||||
.ok { color:#0a7d28; }
|
||||
.err { color:#b00020; }
|
||||
.tag { font-size:12px; padding:2px 8px; border-radius:6px; border:1px solid #e7e7ef; background:#fafafa; }
|
||||
.tag.abierta { border-color:#0a7d28; color:#0a7d28; }
|
||||
.tag.cerrada { border-color:#555; color:#555; }
|
||||
.tag.pagada { border-color:#1b5e20; color:#1b5e20; }
|
||||
.tag.anulada { border-color:#b00020; color:#b00020; }
|
||||
table { width:100%; border-collapse: collapse; }
|
||||
th, td { padding:8px 10px; border-bottom:1px solid #eee; }
|
||||
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🧾 Estado de Comandas</h1>
|
||||
<div style="flex:1"></div>
|
||||
<a class="pill" href="/comandas">➕ Nueva comanda</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Izquierda: listado -->
|
||||
<section class="card">
|
||||
<div class="hd">
|
||||
<strong>Listado</strong>
|
||||
<div style="flex:1"></div>
|
||||
<label class="muted" style="display:flex; gap:6px; align-items:center;">
|
||||
<input id="soloAbiertas" type="checkbox" checked />
|
||||
Solo abiertas
|
||||
</label>
|
||||
</div>
|
||||
<div class="bd">
|
||||
<div class="row" style="margin-bottom:10px;">
|
||||
<input id="buscar" type="search" placeholder="Buscar por #, mesa o usuario…" style="flex:1"/>
|
||||
<button class="btn" id="limpiar">Limpiar</button>
|
||||
</div>
|
||||
<div class="list" id="lista"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Derecha: detalle -->
|
||||
<section class="card">
|
||||
<div class="hd">
|
||||
<strong>Detalle</strong>
|
||||
<div style="flex:1"></div>
|
||||
<span id="detalleEstado" class="tag">—</span>
|
||||
</div>
|
||||
<div class="bd" id="detalle">
|
||||
<div class="muted">Selecciona una comanda para ver el detalle.</div>
|
||||
</div>
|
||||
<div class="sticky-footer">
|
||||
<div class="kpi"><span class="muted">ID:</span><strong id="kpiId">—</strong></div>
|
||||
<div class="kpi" style="margin-left:8px;"><span class="muted">Mesa:</span><strong id="kpiMesa">—</strong></div>
|
||||
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn" id="reabrir">Reabrir</button>
|
||||
<button class="btn primary" id="cerrar">Cerrar</button>
|
||||
</div>
|
||||
<div class="bd">
|
||||
<div id="msg" class="muted"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
|
||||
const state = {
|
||||
filtro: '',
|
||||
soloAbiertas: true,
|
||||
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, observaciones }]
|
||||
sel: null, // id seleccionado
|
||||
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
|
||||
};
|
||||
|
||||
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
||||
const toast = (msg, ok=false) => { const el = $('#msg'); el.className = ok ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
|
||||
|
||||
async function jget(url) {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
async function jpost(url, body) {
|
||||
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ----------- Data -----------
|
||||
async function loadLista() {
|
||||
const estado = state.soloAbiertas ? 'abierta' : '';
|
||||
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
|
||||
const rows = await jget(url);
|
||||
state.lista = rows;
|
||||
renderLista();
|
||||
}
|
||||
|
||||
async function loadDetalle(id) {
|
||||
const det = await jget(`/api/comandas/${id}/detalle`);
|
||||
state.detalle = det;
|
||||
renderDetalle();
|
||||
}
|
||||
|
||||
// ----------- UI: Lista -----------
|
||||
function renderLista() {
|
||||
let rows = state.lista.slice();
|
||||
const f = state.filtro;
|
||||
if (f) {
|
||||
const k = f.toLowerCase();
|
||||
rows = rows.filter(r =>
|
||||
String(r.id_comanda).includes(k) ||
|
||||
(String(r.mesa_numero ?? '').includes(k)) ||
|
||||
((`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(k))
|
||||
);
|
||||
}
|
||||
const box = $('#lista');
|
||||
if (!rows.length) { box.innerHTML = '<div class="muted">Sin resultados.</div>'; return; }
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Mesa</th>
|
||||
<th>Usuario</th>
|
||||
<th>Fecha</th>
|
||||
<th>Estado</th>
|
||||
<th class="right">Items</th>
|
||||
<th class="right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
rows.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.innerHTML = `
|
||||
<td>${r.id_comanda}</td>
|
||||
<td>#${r.mesa_numero} · ${r.mesa_apodo || ''}</td>
|
||||
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
|
||||
<td>${new Date(r.fec_creacion).toLocaleString()}</td>
|
||||
<td><span class="tag ${r.estado}">${r.estado}</span></td>
|
||||
<td class="right">${r.items ?? '—'}</td>
|
||||
<td class="right">${money(r.total ?? 0)}</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
box.innerHTML = '';
|
||||
box.appendChild(tbl);
|
||||
}
|
||||
|
||||
// ----------- UI: Detalle -----------
|
||||
function applyHeader(r) {
|
||||
$('#kpiId').textContent = r.id_comanda ?? '—';
|
||||
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
|
||||
$('#detalleEstado').className = `tag ${r.estado}`;
|
||||
$('#detalleEstado').textContent = r.estado;
|
||||
$('#kpiTotal').textContent = money(r.total ?? 0);
|
||||
|
||||
// Botones según estado
|
||||
const cerr = $('#cerrar'), reab = $('#reabrir');
|
||||
if (r.estado === 'abierta') {
|
||||
cerr.disabled = false; cerr.title = '';
|
||||
reab.disabled = true; reab.title = 'Ya está abierta';
|
||||
} else {
|
||||
cerr.disabled = false; // permitir cerrar (idempotente/override)
|
||||
reab.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetalle() {
|
||||
const box = $('#detalle');
|
||||
if (!state.detalle.length) { box.innerHTML = '<div class="muted">Sin detalle.</div>'; return; }
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="right">Unitario</th>
|
||||
<th class="right">Cantidad</th>
|
||||
<th class="right">Subtotal</th>
|
||||
<th>Observaciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
let total = 0;
|
||||
state.detalle.forEach(r => {
|
||||
total += Number(r.subtotal||0);
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${r.producto_nombre}</td>
|
||||
<td class="right">${money(r.pre_unitario)}</td>
|
||||
<td class="right">${Number(r.cantidad).toFixed(3)}</td>
|
||||
<td class="right">${money(r.subtotal)}</td>
|
||||
<td>${r.observaciones||''}</td>
|
||||
`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
box.innerHTML = '';
|
||||
box.appendChild(tbl);
|
||||
$('#kpiTotal').textContent = money(total);
|
||||
}
|
||||
|
||||
// ----------- Actions -----------
|
||||
async function setEstado(estado) {
|
||||
if (!state.sel) return;
|
||||
try {
|
||||
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
|
||||
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
|
||||
await loadLista();
|
||||
// mantener seleccionada si sigue existiendo en filtro
|
||||
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
|
||||
if (found) { applyHeader(found); await loadDetalle(found.id_comanda); }
|
||||
else {
|
||||
state.sel = null;
|
||||
$('#detalle').innerHTML = '<div class="muted">Selecciona una comanda para ver el detalle.</div>';
|
||||
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'tag';
|
||||
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message || 'No se pudo cambiar el estado');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------- Init -----------
|
||||
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
|
||||
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
|
||||
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
|
||||
$('#cerrar').addEventListener('click', () => setEstado('cerrada'));
|
||||
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
|
||||
|
||||
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user