Re estructuración de patrones de diseño con el código de Manso
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "workarround",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"main": "src/index.mjs",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=production node ./src/index.js",
|
||||
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
|
||||
"test": "NODE_ENV=stage node ./src/index.js"
|
||||
"start": "NODE_ENV=production node ./src/index.mjs",
|
||||
"dev": "NODE_ENV=development npx nodemon ./src/index.mjs",
|
||||
"test": "NODE_ENV=stage node ./src/index.mjs"
|
||||
},
|
||||
"author": "Mateo Saldain",
|
||||
"license": "ISC",
|
||||
@@ -25,6 +25,11 @@
|
||||
"pg-format": "^1.0.4",
|
||||
"serve-favicon": "^2.5.1"
|
||||
},
|
||||
"imports": {
|
||||
"#v1Router": "./src/api/v1/routes/routes.js",
|
||||
"#pages": "./src/pages/pages.js",
|
||||
"#db": "./src/db/poolSingleton.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"description": "Workarround para tener un MVP que llegue al verano para usarse"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// services/manso/src/api/v1/routes/routes.js
|
||||
|
||||
import { Router } from 'express';
|
||||
import pool from '#db'; // Pool Singleton
|
||||
const router = Router();
|
||||
|
||||
// ==========================================================
|
||||
// Rutas de API v1
|
||||
// ==========================================================
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Comandas
|
||||
// ----------------------------------------------------------
|
||||
|
||||
router.route('/comandas').get( async (req, res, next) => {
|
||||
try {
|
||||
var client = await pool.getClient()
|
||||
const estado = (req.query.estado || '').trim() || null;
|
||||
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
|
||||
const { rows } = await client.query(
|
||||
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
|
||||
[estado, limit]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
router.route('/comandas/:id/detalle').get( async (req, res, next) => {
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
client.query(
|
||||
`SELECT id_det_comanda, id_producto, producto_nombre,
|
||||
cantidad, pre_unitario, subtotal, observaciones
|
||||
FROM public.v_comandas_detalle_items
|
||||
WHERE id_comanda = $1::int
|
||||
ORDER BY id_det_comanda`,
|
||||
[req.params.id]
|
||||
)
|
||||
.then(r => res.json(r.rows))
|
||||
.catch(next)
|
||||
client.release();
|
||||
} catch (error) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
router.route('/comandas/:id/cerrar').post( async (req, res, next) => {
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await client.query(
|
||||
`SELECT public.f_cerrar_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
client.release();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.route('/comandas/:id/abrir').post( async (req, res, next) => {
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await client.query(
|
||||
`SELECT public.f_abrir_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
client.release();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Productos
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// GET producto + receta
|
||||
router.route('/rpc/get_producto/:id').get( async (req, res) => {
|
||||
const client = await pool.getClient()
|
||||
const id = Number(req.params.id);
|
||||
const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]);
|
||||
res.json(rows[0]?.data || {});
|
||||
client.release();
|
||||
});
|
||||
|
||||
// POST guardar producto + receta
|
||||
router.route('/rpc/save_producto').post(async (req, res) => {
|
||||
try {
|
||||
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
|
||||
const client = await pool.getClient()
|
||||
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 client.query(q, params);
|
||||
res.json(rows[0] || {});
|
||||
client.release();
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'save_producto failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Materias Primas
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// GET MP + proveedores
|
||||
router.route('/rpc/get_materia/:id').get(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
try {
|
||||
const client = await pool.getClient()
|
||||
const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]);
|
||||
res.json(rows[0]?.data || {});
|
||||
client.release();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'get_materia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// SAVE MP + proveedores (array)
|
||||
router.route('/rpc/save_materia').post( 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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Usuarios y Asistencias
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
|
||||
router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => {
|
||||
try {
|
||||
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
|
||||
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
|
||||
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
|
||||
router.route('/rpc/import_asistencia').post( async (req, res) => {
|
||||
try {
|
||||
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
|
||||
const origen = req.body?.origen || null;
|
||||
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
|
||||
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'import_asistencia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Consultar datos de asistencia (raw + pares) para un usuario y rango
|
||||
router.route('/rpc/asistencia_get').post( async (req, res) => {
|
||||
try {
|
||||
const { doc, desde, hasta } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
|
||||
const { rows } = await pool.query(sql, [doc, desde, hasta]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Editar un registro crudo y recalcular pares
|
||||
router.route('/rpc/asistencia_update_raw').post( async (req, res) => {
|
||||
try {
|
||||
const { id_raw, fecha, hora, modo } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
|
||||
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar un registro crudo y recalcular pares
|
||||
router.route('/rpc/asistencia_delete_raw').post( async (req, res) => {
|
||||
try {
|
||||
const { id_raw } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
|
||||
const { rows } = await pool.query(sql, [id_raw]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Reportes
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// POST /api/rpc/report_tickets { year }
|
||||
router.route('/rpc/report_tickets').post( async (req, res) => {
|
||||
try {
|
||||
const y = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
|
||||
? y
|
||||
: (new Date()).getFullYear();
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_tickets_year($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_tickets error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_tickets failed',
|
||||
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
|
||||
router.route('/rpc/report_asistencia').post( async (req, res) => {
|
||||
try {
|
||||
let { desde, hasta } = req.body || {};
|
||||
// defaults si vienen vacíos/invalidos
|
||||
const re = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!re.test(desde) || !re.test(hasta)) {
|
||||
const end = new Date();
|
||||
const start = new Date(end); start.setDate(end.getDate()-30);
|
||||
desde = start.toISOString().slice(0,10);
|
||||
hasta = end.toISOString().slice(0,10);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_asistencia error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_asistencia failed',
|
||||
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API Compras y Gastos
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// Guardar (insert/update)
|
||||
router.route('/rpc/save_compra').post( async (req, res) => {
|
||||
try {
|
||||
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
|
||||
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
|
||||
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
|
||||
const { rows } = await pool.query(sql, args);
|
||||
res.json(rows[0]); // { id_compra, total }
|
||||
} catch (e) {
|
||||
console.error('save_compra error:', e);
|
||||
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Obtener para editar
|
||||
router.route('/rpc/get_compra').post( async (req, res) => {
|
||||
try {
|
||||
const { id_compra } = req.body || {};
|
||||
const sql = `SELECT public.get_compra($1::int) AS data`;
|
||||
const { rows } = await pool.query(sql, [id_compra]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'get_compra failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar
|
||||
router.route('/rpc/delete_compra').post( async (req, res) => {
|
||||
try {
|
||||
const { id_compra } = req.body || {};
|
||||
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// POST /api/rpc/report_gastos { year: 2025 }
|
||||
router.route('/rpc/report_gastos').post( async (req, res) => {
|
||||
try {
|
||||
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_gastos error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_gastos failed',
|
||||
message: e.message, detail: e.detail, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Coneción Singleton a base de datos.
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
|
||||
if (Database.instance) {
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||
};
|
||||
|
||||
this.connection = new Pool(config);
|
||||
|
||||
Database.instance = this;
|
||||
}
|
||||
async query(sql, params) {
|
||||
return this.connection.query(sql,params);
|
||||
}
|
||||
|
||||
async connect() { /* Definida solo para evitar errores */
|
||||
return this.connection.connect();
|
||||
}
|
||||
async getClient() {
|
||||
return this.connection.connect();
|
||||
}
|
||||
|
||||
async release() {
|
||||
await this.connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// const db = new Database();
|
||||
// db.query('SELECT * FROM users');
|
||||
|
||||
const pool = new Database();
|
||||
export default pool;
|
||||
export { Database };
|
||||
@@ -1,842 +0,0 @@
|
||||
// app/src/index.js
|
||||
import chalk from 'chalk'; // Colores!
|
||||
import favicon from 'serve-favicon'; // Favicon
|
||||
import express from 'express';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
import cors from 'cors';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
// Rutas
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Variables de Entorno
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Cargar .env según entorno
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.development') });
|
||||
} else if (process.env.NODE_ENV === 'test') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.test') });
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.production') });
|
||||
} else {
|
||||
dotenv.config(); // .env por defecto
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// App
|
||||
// ----------------------------------------------------------
|
||||
const app = express();
|
||||
app.set('trust proxy', true);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.static(path.join(__dirname, 'pages')));
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Motor de vistas EJS
|
||||
// ----------------------------------------------------------
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
app.set("view engine", "ejs");
|
||||
app.use(expressLayouts);
|
||||
app.set("layout", "layouts/main");
|
||||
|
||||
// Archivos estáticos
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
|
||||
app.use('/favicon', express.static(path.join(__dirname, 'public', 'favicon'), {
|
||||
maxAge: '1y'
|
||||
}));
|
||||
|
||||
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), {
|
||||
maxAge: '1y'
|
||||
}));
|
||||
|
||||
const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Configuración de conexión PostgreSQL
|
||||
// ----------------------------------------------------------
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||
};
|
||||
|
||||
const pool = new Pool(dbConfig);
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Seguridad: Tablas permitidas
|
||||
// ----------------------------------------------------------
|
||||
const ALLOWED_TABLES = [
|
||||
'roles','usuarios','usua_roles',
|
||||
'categorias','productos',
|
||||
'clientes','mesas',
|
||||
'comandas','deta_comandas',
|
||||
'proveedores','compras','deta_comp_producto',
|
||||
'mate_primas','deta_comp_materias',
|
||||
'prov_producto','prov_mate_prima',
|
||||
'receta_producto', 'asistencia_resumen_diario',
|
||||
'asistencia_intervalo', 'vw_compras'
|
||||
];
|
||||
|
||||
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
// Identificadores SQL -> comillas dobles y escape correcto
|
||||
const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
|
||||
|
||||
function ensureTable(name) {
|
||||
const t = String(name || '').toLowerCase();
|
||||
if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida');
|
||||
return t;
|
||||
}
|
||||
|
||||
async function getClient() {
|
||||
const client = await pool.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Introspección de esquema
|
||||
// ----------------------------------------------------------
|
||||
async function loadColumns(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
c.column_name,
|
||||
c.data_type,
|
||||
c.is_nullable = 'YES' AS is_nullable,
|
||||
c.column_default,
|
||||
(SELECT EXISTS (
|
||||
SELECT 1 FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
|
||||
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
|
||||
)) AS is_primary,
|
||||
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
WHERE t.relname = $1 AND a.attname = c.column_name
|
||||
) AS is_identity
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema='public' AND c.table_name=$1
|
||||
ORDER BY c.ordinal_position
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function loadForeignKeys(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
kcu.column_name,
|
||||
ccu.table_name AS foreign_table,
|
||||
ccu.column_name AS foreign_column
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
const map = {};
|
||||
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
|
||||
return map;
|
||||
}
|
||||
|
||||
async function loadPrimaryKey(client, table) {
|
||||
const sql = `
|
||||
SELECT a.attname AS column_name
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
JOIN pg_class t ON t.oid = i.indrelid
|
||||
WHERE t.relname = $1 AND i.indisprimary
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows.map(r => r.column_name);
|
||||
}
|
||||
|
||||
// label column for FK options
|
||||
async function pickLabelColumn(client, refTable) {
|
||||
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono'];
|
||||
const { rows } = await client.query(
|
||||
`SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=$1
|
||||
ORDER BY ordinal_position`, [refTable]
|
||||
);
|
||||
for (const cand of preferred) {
|
||||
if (rows.find(r => r.column_name === cand)) return cand;
|
||||
}
|
||||
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
|
||||
if (textish) return textish.column_name;
|
||||
return rows[0]?.column_name || 'id';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Middleware para datos globales
|
||||
// ----------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.pageTitle = "SuiteCoffee";
|
||||
res.locals.pageId = "";
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Rutas de UI
|
||||
// ----------------------------------------------------------
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.locals.pageTitle = "Dashboard";
|
||||
res.locals.pageId = "home"; // para el sidebar contextual
|
||||
res.render("dashboard");
|
||||
});
|
||||
|
||||
app.get("/dashboard", (req, res) => {
|
||||
res.locals.pageTitle = "Dashboard";
|
||||
res.locals.pageId = "dashboard"; // <- importante
|
||||
res.render("dashboard");
|
||||
});
|
||||
|
||||
// app.get('/', (req, res) => {
|
||||
// res.sendFile(path.join(__dirname, 'pages', 'dashboard.html'));
|
||||
// });
|
||||
|
||||
app.get("/comandas", (req, res) => {
|
||||
res.locals.pageTitle = "Comandas";
|
||||
res.locals.pageId = "comandas"; // <- importante para el sidebar contextual
|
||||
res.render("comandas");
|
||||
});
|
||||
|
||||
// app.get('/comandas', (req, res) => {
|
||||
// res.sendFile(path.join(__dirname, 'pages', 'comandas.html'));
|
||||
// });
|
||||
|
||||
app.get("/estadoComandas", (req, res) => {
|
||||
res.locals.pageTitle = "Estado de Comandas";
|
||||
res.locals.pageId = "estadoComandas";
|
||||
res.render("estadoComandas");
|
||||
});
|
||||
|
||||
// app.get('/estadoComandas', (req, res) => {
|
||||
// res.sendFile(path.join(__dirname, 'pages', 'estadoComandas.html'));
|
||||
// });
|
||||
|
||||
app.get("/productos", (req, res) => {
|
||||
res.locals.pageTitle = "Productos";
|
||||
res.locals.pageId = "productos";
|
||||
res.render("productos");
|
||||
});
|
||||
|
||||
app.get('/usuarios', (req, res) => {
|
||||
res.locals.pageTitle = 'Usuarios';
|
||||
res.locals.pageId = 'usuarios';
|
||||
res.render('usuarios');
|
||||
});
|
||||
|
||||
app.get('/reportes', (req, res) => {
|
||||
res.locals.pageTitle = 'Reportes';
|
||||
res.locals.pageId = 'reportes';
|
||||
res.render('reportes');
|
||||
});
|
||||
|
||||
app.get('/compras', (req, res) => {
|
||||
res.locals.pageTitle = 'Compras';
|
||||
res.locals.pageId = 'compras';
|
||||
res.render('compras');
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API
|
||||
// ----------------------------------------------------------
|
||||
app.get('/api/tables', async (_req, res) => {
|
||||
res.json(ALLOWED_TABLES);
|
||||
});
|
||||
|
||||
app.get('/api/schema/:table', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const client = await getClient();
|
||||
try {
|
||||
const columns = await loadColumns(client, table);
|
||||
const fks = await loadForeignKeys(client, table);
|
||||
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
|
||||
res.json({ table, columns: enriched });
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/options/:table/:column', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const column = req.params.column;
|
||||
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
|
||||
|
||||
const client = await getClient();
|
||||
try {
|
||||
const fks = await loadForeignKeys(client, table);
|
||||
const fk = fks[column];
|
||||
if (!fk) return res.json([]);
|
||||
|
||||
const refTable = fk.foreign_table;
|
||||
const refId = fk.foreign_column;
|
||||
const labelCol = await pickLabelColumn(client, refTable);
|
||||
|
||||
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
|
||||
const result = await client.query(sql);
|
||||
res.json(result.rows);
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/table/:table', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||
const client = await getClient();
|
||||
try {
|
||||
const pks = await loadPrimaryKey(client, table);
|
||||
const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
|
||||
const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
|
||||
const result = await client.query(sql);
|
||||
|
||||
// Normalizar: siempre devolver objetos {col: valor}
|
||||
const colNames = result.fields.map(f => f.name);
|
||||
let rows = result.rows;
|
||||
if (rows.length && Array.isArray(rows[0])) {
|
||||
rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
|
||||
}
|
||||
res.json(rows);
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/table/:table', async (req, res) => {
|
||||
const table = ensureTable(req.params.table);
|
||||
const payload = req.body || {};
|
||||
try {
|
||||
const client = await getClient();
|
||||
try {
|
||||
const columns = await loadColumns(client, table);
|
||||
const insertable = columns.filter(c =>
|
||||
!c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
|
||||
);
|
||||
const allowedCols = new Set(insertable.map(c => c.column_name));
|
||||
|
||||
const cols = [];
|
||||
const vals = [];
|
||||
const params = [];
|
||||
let idx = 1;
|
||||
for (const [k, v] of Object.entries(payload)) {
|
||||
if (!allowedCols.has(k)) continue;
|
||||
if (!VALID_IDENT.test(k)) continue;
|
||||
cols.push(q(k));
|
||||
vals.push(`$${idx++}`);
|
||||
params.push(v);
|
||||
}
|
||||
|
||||
if (!cols.length) {
|
||||
const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
|
||||
res.status(201).json({ inserted: rows[0] });
|
||||
} else {
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
|
||||
params
|
||||
);
|
||||
res.status(201).json({ inserted: rows[0] });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||
throw e;
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/comandas', async (req, res, next) => {
|
||||
try {
|
||||
const estado = (req.query.estado || '').trim() || null;
|
||||
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
|
||||
[estado, limit]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
|
||||
// app.get('/api/comandas', async (req, res, next) => {
|
||||
// try {
|
||||
// const estado = (req.query.estado || '').trim();
|
||||
// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
// const params = [];
|
||||
// let where = '';
|
||||
// if (estado) { params.push(estado); where = `WHERE c.estado = $${params.length}`; }
|
||||
// params.push(limit);
|
||||
|
||||
// const sql = `
|
||||
// WITH items AS (
|
||||
// SELECT d.id_comanda,
|
||||
// COUNT(*) AS items,
|
||||
// SUM(d.cantidad * d.pre_unitario) AS total
|
||||
// FROM deta_comandas d
|
||||
// GROUP BY d.id_comanda
|
||||
// )
|
||||
// SELECT
|
||||
// c.id_comanda, c.fec_creacion, c.estado, c.observaciones,
|
||||
// u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido,
|
||||
// m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo,
|
||||
// COALESCE(i.items, 0) AS items,
|
||||
// COALESCE(i.total, 0) AS total
|
||||
// FROM comandas c
|
||||
// JOIN usuarios u ON u.id_usuario = c.id_usuario
|
||||
// JOIN mesas m ON m.id_mesa = c.id_mesa
|
||||
// LEFT JOIN items i ON i.id_comanda = c.id_comanda
|
||||
// ${where}
|
||||
// ORDER BY c.id_comanda DESC
|
||||
// LIMIT $${params.length}
|
||||
// `;
|
||||
// const client = await pool.connect();
|
||||
// try {
|
||||
// const { rows } = await client.query(sql, params);
|
||||
// res.json(rows);
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) { next(e); }
|
||||
// });
|
||||
|
||||
|
||||
// Detalle de una comanda (con nombres de productos)
|
||||
|
||||
// GET /api/comandas/:id/detalle
|
||||
app.get('/api/comandas/:id/detalle', (req, res, next) =>
|
||||
pool.query(
|
||||
`SELECT id_det_comanda, id_producto, producto_nombre,
|
||||
cantidad, pre_unitario, subtotal, observaciones
|
||||
FROM public.v_comandas_detalle_items
|
||||
WHERE id_comanda = $1::int
|
||||
ORDER BY id_det_comanda`,
|
||||
[req.params.id]
|
||||
)
|
||||
.then(r => res.json(r.rows))
|
||||
.catch(next)
|
||||
);
|
||||
|
||||
|
||||
// app.get('/api/comandas/:id/detalle', async (req, res, next) => {
|
||||
// try {
|
||||
// const id = parseInt(req.params.id, 10);
|
||||
// if (!Number.isInteger(id) || id <= 0) {
|
||||
// return res.status(400).json({ error: 'id inválido' });
|
||||
// }
|
||||
|
||||
// const sql = `
|
||||
// SELECT
|
||||
// id_det_comanda, id_producto, producto_nombre,
|
||||
// cantidad, pre_unitario, subtotal, observaciones
|
||||
// FROM public.v_comandas_detalle_items
|
||||
// WHERE id_comanda = $1::int
|
||||
// ORDER BY id_det_comanda
|
||||
// `;
|
||||
// const { rows } = await pool.query(sql, [id]);
|
||||
// res.json(rows);
|
||||
// } catch (e) { next(e); }
|
||||
// });
|
||||
|
||||
|
||||
// app.get('/api/comandas/:id/detalle', async (req, res, next) => {
|
||||
// try {
|
||||
// const id = parseInt(req.params.id, 10);
|
||||
// if (!id) return res.status(400).json({ error: 'id inválido' });
|
||||
|
||||
// const sql = `
|
||||
// SELECT d.id_det_comanda, d.id_producto, p.nombre AS producto_nombre,
|
||||
// d.cantidad, d.pre_unitario, (d.cantidad * d.pre_unitario) AS subtotal,
|
||||
// d.observaciones
|
||||
// FROM deta_comandas d
|
||||
// JOIN productos p ON p.id_producto = d.id_producto
|
||||
// WHERE d.id_comanda = $1
|
||||
// ORDER BY d.id_det_comanda
|
||||
// `;
|
||||
// const { rows } = await pool.query(sql, [id]);
|
||||
// res.json(rows);
|
||||
// } catch (e) { next(e); }
|
||||
// });
|
||||
|
||||
|
||||
|
||||
// Cerrar comanda (setea estado y fec_cierre en DB)
|
||||
app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT public.f_cerrar_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Abrir (reabrir) comanda
|
||||
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT public.f_abrir_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// // Cambiar estado (abrir/cerrar)
|
||||
// app.post('/api/comandas/:id/estado', async (req, res, next) => {
|
||||
// try {
|
||||
// const id = parseInt(req.params.id, 10);
|
||||
// let { estado } = req.body || {};
|
||||
// if (!id) return res.status(400).json({ error: 'id inválido' });
|
||||
|
||||
// const allowed = new Set(['abierta','cerrada','pagada','anulada']);
|
||||
// if (!allowed.has(estado)) return res.status(400).json({ error: 'estado inválido' });
|
||||
|
||||
// const { rows } = await pool.query(
|
||||
// `UPDATE comandas SET estado = $2 WHERE id_comanda = $1 RETURNING *`,
|
||||
// [id, estado]
|
||||
// );
|
||||
// if (!rows.length) return res.status(404).json({ error: 'comanda no encontrada' });
|
||||
// res.json({ updated: rows[0] });
|
||||
// } 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
|
||||
app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
|
||||
try {
|
||||
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
|
||||
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
|
||||
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
|
||||
app.post('/api/rpc/import_asistencia', async (req, res) => {
|
||||
try {
|
||||
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
|
||||
const origen = req.body?.origen || null;
|
||||
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
|
||||
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'import_asistencia failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Consultar datos de asistencia (raw + pares) para un usuario y rango
|
||||
app.post('/api/rpc/asistencia_get', async (req, res) => {
|
||||
try {
|
||||
const { doc, desde, hasta } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
|
||||
const { rows } = await pool.query(sql, [doc, desde, hasta]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Editar un registro crudo y recalcular pares
|
||||
app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
|
||||
try {
|
||||
const { id_raw, fecha, hora, modo } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
|
||||
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar un registro crudo y recalcular pares
|
||||
app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
|
||||
try {
|
||||
const { id_raw } = req.body || {};
|
||||
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
|
||||
const { rows } = await pool.query(sql, [id_raw]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/report_tickets { year }
|
||||
app.post('/api/rpc/report_tickets', async (req, res) => {
|
||||
try {
|
||||
const y = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
|
||||
? y
|
||||
: (new Date()).getFullYear();
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_tickets_year($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_tickets error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_tickets failed',
|
||||
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
|
||||
app.post('/api/rpc/report_asistencia', async (req, res) => {
|
||||
try {
|
||||
let { desde, hasta } = req.body || {};
|
||||
// defaults si vienen vacíos/invalidos
|
||||
const re = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!re.test(desde) || !re.test(hasta)) {
|
||||
const end = new Date();
|
||||
const start = new Date(end); start.setDate(end.getDate()-30);
|
||||
desde = start.toISOString().slice(0,10);
|
||||
hasta = end.toISOString().slice(0,10);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_asistencia error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_asistencia failed',
|
||||
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// app.post('/api/rpc/report_asistencia', async (req,res)=>{
|
||||
// try{
|
||||
// const {desde, hasta} = req.body||{};
|
||||
// const sql = 'SELECT * FROM public.report_asistencia($1::date,$2::date)';
|
||||
// const {rows} = await pool.query(sql,[desde, hasta]);
|
||||
// res.json(rows);
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'report_tickets failed' + e });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.post('/api/rpc/report_tickets', async (req, res) => {
|
||||
// try {
|
||||
// const { year } = req.body || {};
|
||||
// const sql = 'SELECT public.report_tickets_year($1::int) AS data';
|
||||
// const { rows } = await pool.query(sql, [year]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'report_tickets failed' + e });
|
||||
// }
|
||||
// });
|
||||
|
||||
|
||||
// Guardar (insert/update)
|
||||
app.post('/api/rpc/save_compra', async (req, res) => {
|
||||
try {
|
||||
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
|
||||
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
|
||||
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
|
||||
const { rows } = await pool.query(sql, args);
|
||||
res.json(rows[0]); // { id_compra, total }
|
||||
} catch (e) {
|
||||
console.error('save_compra error:', e);
|
||||
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Obtener para editar
|
||||
app.post('/api/rpc/get_compra', async (req, res) => {
|
||||
try {
|
||||
const { id_compra } = req.body || {};
|
||||
const sql = `SELECT public.get_compra($1::int) AS data`;
|
||||
const { rows } = await pool.query(sql, [id_compra]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'get_compra failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar
|
||||
app.post('/api/rpc/delete_compra', async (req, res) => {
|
||||
try {
|
||||
const { id_compra } = req.body || {};
|
||||
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// POST /api/rpc/report_gastos { year: 2025 }
|
||||
app.post('/api/rpc/report_gastos', async (req, res) => {
|
||||
try {
|
||||
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_gastos error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_gastos failed',
|
||||
message: e.message, detail: e.detail, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// (Opcional) GET para probar rápido desde el navegador:
|
||||
// /api/rpc/report_gastos?year=2025
|
||||
app.get('/api/rpc/report_gastos', async (req, res) => {
|
||||
try {
|
||||
const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_gastos error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_gastos failed',
|
||||
message: e.message, detail: e.detail, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
async function verificarConexion() {
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
const res = await client.query('SELECT NOW() AS hora');
|
||||
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
|
||||
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error('Revisar credenciales y accesos de red.');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Inicio del servidor
|
||||
// ----------------------------------------------------------
|
||||
|
||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Servidor de aplicación escuchando en ${chalk.yellow(`http://localhost:${PORT}`)}`);
|
||||
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.white(process.env.DB_NAME)} del host ${chalk.white(process.env.DB_HOST)} ...`));
|
||||
verificarConexion();
|
||||
});
|
||||
|
||||
// Healthcheck
|
||||
app.get('/health', async (_req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
// ./services/manso/src/index.mjs
|
||||
|
||||
import 'dotenv/config';// Variables de Entorno
|
||||
|
||||
import favicon from 'serve-favicon'; // Favicon
|
||||
import express from 'express';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
|
||||
import pool from '#db'; // Pool Singleton
|
||||
import v1Router from '#v1Router'; // Rutas API v1
|
||||
import expressPages from '#pages'; // Rutas "/", "/dashboard", ...
|
||||
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Variables del sistema
|
||||
// ----------------------------------------------------------
|
||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||
const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
|
||||
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
const q = (s) => `"${String(s).replace(/"/g, '""')}"`; // Identificadores SQL -> comillas dobles y escape correcto
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// App + Motor de vistas EJS
|
||||
// ----------------------------------------------------------
|
||||
const app = express();
|
||||
app.set('trust proxy', true);
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
app.set("view engine", "ejs");
|
||||
app.set("layout", "layouts/main");
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public'))); // Carga de archivos estaticos
|
||||
app.use(expressLayouts); // Carga los layouts que usara el renderizado
|
||||
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), {maxAge: '1y'}));
|
||||
app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Uso de API v1
|
||||
// ----------------------------------------------------------
|
||||
app.use("/api/v1", v1Router);
|
||||
|
||||
// /api/rpc/get_producto/:id
|
||||
// /api/v1/rpc/get_producto/:id -> /rpc/get_producto/:id
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Seguridad: Tablas permitidas
|
||||
// ----------------------------------------------------------
|
||||
const ALLOWED_TABLES = [
|
||||
'roles','usuarios','usua_roles',
|
||||
'categorias','productos',
|
||||
'clientes','mesas',
|
||||
'comandas','deta_comandas',
|
||||
'proveedores','compras','deta_comp_producto',
|
||||
'mate_primas','deta_comp_materias',
|
||||
'prov_producto','prov_mate_prima',
|
||||
'receta_producto', 'asistencia_resumen_diario',
|
||||
'asistencia_intervalo', 'asistencia_detalle',
|
||||
'vw_compras'
|
||||
];
|
||||
|
||||
function ensureTable(name) {
|
||||
const t = String(name || '').toLowerCase();
|
||||
if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida');
|
||||
return t;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Introspección de esquema
|
||||
// ----------------------------------------------------------
|
||||
async function loadColumns(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
c.column_name,
|
||||
c.data_type,
|
||||
c.is_nullable = 'YES' AS is_nullable,
|
||||
c.column_default,
|
||||
(SELECT EXISTS (
|
||||
SELECT 1 FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
|
||||
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
|
||||
)) AS is_primary,
|
||||
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
WHERE t.relname = $1 AND a.attname = c.column_name
|
||||
) AS is_identity
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema='public' AND c.table_name=$1
|
||||
ORDER BY c.ordinal_position
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function loadForeignKeys(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
kcu.column_name,
|
||||
ccu.table_name AS foreign_table,
|
||||
ccu.column_name AS foreign_column
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
const map = {};
|
||||
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
|
||||
return map;
|
||||
}
|
||||
|
||||
async function loadPrimaryKey(client, table) {
|
||||
const sql = `
|
||||
SELECT a.attname AS column_name
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
JOIN pg_class t ON t.oid = i.indrelid
|
||||
WHERE t.relname = $1 AND i.indisprimary
|
||||
`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows.map(r => r.column_name);
|
||||
}
|
||||
|
||||
// label column for FK options
|
||||
async function pickLabelColumn(client, refTable) {
|
||||
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono'];
|
||||
const { rows } = await client.query(
|
||||
`SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=$1
|
||||
ORDER BY ordinal_position`, [refTable]
|
||||
);
|
||||
for (const cand of preferred) {
|
||||
if (rows.find(r => r.column_name === cand)) return cand;
|
||||
}
|
||||
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
|
||||
if (textish) return textish.column_name;
|
||||
return rows[0]?.column_name || 'id';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Middleware para datos globales
|
||||
// ----------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.pageTitle = "SuiteCoffee";
|
||||
res.locals.pageId = "";
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API
|
||||
// ----------------------------------------------------------
|
||||
app.get('/api/tables', async (_req, res) => {
|
||||
res.json(ALLOWED_TABLES);
|
||||
});
|
||||
|
||||
app.get('/api/schema/:table', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const columns = await loadColumns(client, table);
|
||||
const fks = await loadForeignKeys(client, table);
|
||||
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
|
||||
res.json({ table, columns: enriched });
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/options/:table/:column', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const column = req.params.column;
|
||||
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
|
||||
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const fks = await loadForeignKeys(client, table);
|
||||
const fk = fks[column];
|
||||
if (!fk) return res.json([]);
|
||||
|
||||
const refTable = fk.foreign_table;
|
||||
const refId = fk.foreign_column;
|
||||
const labelCol = await pickLabelColumn(client, refTable);
|
||||
|
||||
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
|
||||
const result = await client.query(sql);
|
||||
res.json(result.rows);
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/table/:table', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const pks = await loadPrimaryKey(client, table);
|
||||
const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
|
||||
const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
|
||||
const result = await client.query(sql);
|
||||
|
||||
// Normalizar: siempre devolver objetos {col: valor}
|
||||
const colNames = result.fields.map(f => f.name);
|
||||
let rows = result.rows;
|
||||
if (rows.length && Array.isArray(rows[0])) {
|
||||
rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
|
||||
}
|
||||
res.json(rows);
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/table/:table', async (req, res) => {
|
||||
const table = ensureTable(req.params.table);
|
||||
const payload = req.body || {};
|
||||
try {
|
||||
const client = await pool.getClient();
|
||||
try {
|
||||
const columns = await loadColumns(client, table);
|
||||
const insertable = columns.filter(c =>
|
||||
!c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
|
||||
);
|
||||
const allowedCols = new Set(insertable.map(c => c.column_name));
|
||||
|
||||
const cols = [];
|
||||
const vals = [];
|
||||
const params = [];
|
||||
let idx = 1;
|
||||
for (const [k, v] of Object.entries(payload)) {
|
||||
if (!allowedCols.has(k)) continue;
|
||||
if (!VALID_IDENT.test(k)) continue;
|
||||
cols.push(q(k));
|
||||
vals.push(`$${idx++}`);
|
||||
params.push(v);
|
||||
}
|
||||
|
||||
if (!cols.length) {
|
||||
const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
|
||||
res.status(201).json({ inserted: rows[0] });
|
||||
} else {
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
|
||||
params
|
||||
);
|
||||
res.status(201).json({ inserted: rows[0] });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
|
||||
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
|
||||
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
|
||||
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
|
||||
throw e;
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
async function verificarConexion() {
|
||||
try {
|
||||
var client = await pool.getClient();
|
||||
let res = await client.query('SELECT NOW() AS hora');
|
||||
console.log(`\nConexión con la base de datos ${process.env.DB_NAME} fue exitosa.`);
|
||||
console.log('Fecha y hora actual de la base de datos:\n ->', res.rows[0].hora);
|
||||
} catch (error) {
|
||||
console.error('Error al conectar con la base de datos al iniciar: \n ->', error.message);
|
||||
console.error('Revisar credenciales y accesos de red.');
|
||||
} finally{
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Inicio del servidor
|
||||
// ----------------------------------------------------------
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Servidor de aplicación escuchando en ${`http://localhost:${PORT}`}`);
|
||||
console.log(`Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
|
||||
verificarConexion();
|
||||
});
|
||||
|
||||
// Healthcheck
|
||||
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));
|
||||
@@ -1,355 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,293 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,280 +0,0 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,67 @@
|
||||
// services/manso/src/api/v1/routes/routes.js
|
||||
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Rutas de UI
|
||||
// ----------------------------------------------------------
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.locals.pageTitle = "Inicio"; // Título de pestaña
|
||||
res.locals.pageId = "home"; // Sidebar contextual
|
||||
res.render("dashboard"); // Archivo .ejs a renderizar
|
||||
// res.json({ ok: true, route: '/inicio' }); // Debug json
|
||||
});
|
||||
|
||||
router.get('/dashboard', (req, res) => {
|
||||
res.locals.pageTitle = "Dashboard";
|
||||
res.locals.pageId = "dashboard";
|
||||
res.render("dashboard");
|
||||
// res.json({ ok: true, route: '/dashboard' });
|
||||
});
|
||||
|
||||
router.get('/comandas', (req, res) => {
|
||||
res.locals.pageTitle = "Comandas";
|
||||
res.locals.pageId = "comandas";
|
||||
res.render("comandas");
|
||||
// res.json({ ok: true, route: '/comandas' });
|
||||
});
|
||||
|
||||
router.get('/estadoComandas', (req, res) => {
|
||||
res.locals.pageTitle = "Estado";
|
||||
res.locals.pageId = "estadoComandas";
|
||||
res.render("estadoComandas");
|
||||
// res.json({ ok: true, route: '/estadoComandas' });
|
||||
});
|
||||
|
||||
router.get('/productos', (req, res) => {
|
||||
res.locals.pageTitle = "Propductos";
|
||||
res.locals.pageId = "productos";
|
||||
res.render("productos");
|
||||
// res.json({ ok: true, route: '/productos' });
|
||||
});
|
||||
|
||||
router.get('/usuarios', (req, res) => {
|
||||
res.locals.pageTitle = "Usuarios";
|
||||
res.locals.pageId = "usuarios";
|
||||
res.render("usuarios");
|
||||
// res.json({ ok: true, route: '/usuarios' });
|
||||
});
|
||||
|
||||
router.get('/reportes', (req, res) => {
|
||||
res.locals.pageTitle = "Reportes";
|
||||
res.locals.pageId = "reportes";
|
||||
res.render("reportes");
|
||||
// res.json({ ok: true, route: '/reportes' });
|
||||
});
|
||||
|
||||
router.get('/compras', (req, res) => {
|
||||
res.locals.pageTitle = "Compras";
|
||||
res.locals.pageId = "compras";
|
||||
res.render("compras");
|
||||
// res.json({ ok: true, route: '/compras' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -253,7 +253,7 @@ async function saveCompra(){
|
||||
|
||||
$('#btnGuardar').disabled = true; $('#status').textContent = 'Guardando…';
|
||||
try{
|
||||
const res = await jpost('/api/rpc/save_compra', payload);
|
||||
const res = await jpost('/api/v1/rpc/save_compra', payload);
|
||||
$('#id_compra').value = res.id_compra;
|
||||
$('#btnEliminar').classList.remove('d-none');
|
||||
$('#formTitle').textContent = 'Editar compra #' + res.id_compra;
|
||||
@@ -272,7 +272,7 @@ async function deleteCompra(){
|
||||
if (!confirm('¿Eliminar compra #' + id + '?')) return;
|
||||
$('#btnEliminar').disabled = true;
|
||||
try{
|
||||
await jpost('/api/rpc/delete_compra', { id_compra: id });
|
||||
await jpost('/api/v1/rpc/delete_compra', { id_compra: id });
|
||||
nuevaCompra();
|
||||
await loadListado();
|
||||
}catch(e){
|
||||
@@ -295,7 +295,7 @@ function nuevaCompra(){
|
||||
async function cargarCompra(id){
|
||||
$('#status').textContent = 'Cargando compra…';
|
||||
try{
|
||||
const data = await jpost('/api/rpc/get_compra', { id_compra: id });
|
||||
const data = await jpost('/api/v1/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);
|
||||
|
||||
@@ -105,14 +105,14 @@
|
||||
// ===== Data =====
|
||||
async function loadLista() {
|
||||
const estado = state.soloAbiertas ? 'abierta' : '';
|
||||
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
|
||||
const url = estado ? `/api/v1/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/v1/comandas?limit=300';
|
||||
const rows = await jget(url);
|
||||
state.lista = Array.isArray(rows) ? rows : [];
|
||||
renderLista();
|
||||
}
|
||||
|
||||
async function loadDetalle(id) {
|
||||
const det = await jget(`/api/comandas/${id}/detalle`);
|
||||
const det = await jget(`/api/v1/comandas/${id}/detalle`);
|
||||
state.detalle = Array.isArray(det) ? det : [];
|
||||
renderDetalle();
|
||||
}
|
||||
@@ -241,7 +241,7 @@
|
||||
async function accionComanda(accion){ // 'abrir' | 'cerrar'
|
||||
if (!state.sel) return;
|
||||
try {
|
||||
await jpost(`/api/comandas/${state.sel}/${accion}`, {}); // el body no se usa en el backend
|
||||
await jpost(`/api/v1/comandas/${state.sel}/${accion}`, {}); // el body no se usa en el backend
|
||||
toast(`Comanda #${state.sel} ${accion === 'abrir' ? 'reabierta' : 'cerrada'}`, true);
|
||||
|
||||
// Recargar lista y re-aplicar cabecera/detalle si sigue visible
|
||||
@@ -346,14 +346,14 @@
|
||||
// ===== Data =====
|
||||
async function loadLista() {
|
||||
const estado = state.soloAbiertas ? 'abierta' : '';
|
||||
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
|
||||
const url = estado ? `/api/v1/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/v1/comandas?limit=300';
|
||||
const rows = await jget(url);
|
||||
state.lista = Array.isArray(rows) ? rows : [];
|
||||
renderLista();
|
||||
}
|
||||
|
||||
async function loadDetalle(id) {
|
||||
const det = await jget(`/api/comandas/${id}/detalle`);
|
||||
const det = await jget(`/api/v1/comandas/${id}/detalle`);
|
||||
state.detalle = Array.isArray(det) ? det : [];
|
||||
renderDetalle();
|
||||
}
|
||||
@@ -478,7 +478,7 @@
|
||||
async function setEstado(estado){
|
||||
if (!state.sel) return;
|
||||
try {
|
||||
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
|
||||
const { updated } = await jpost(`/api/v1/comandas/${state.sel}/estado`, { estado });
|
||||
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
|
||||
await loadLista();
|
||||
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
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 data = await jget(`/api/v1/rpc/get_producto/${id}`);
|
||||
const p = data.producto || {};
|
||||
const r = Array.isArray(data.receta) ? data.receta : [];
|
||||
|
||||
@@ -384,7 +384,7 @@
|
||||
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);
|
||||
const { id_producto } = await jpost('/api/v1/rpc/save_producto', payload);
|
||||
toast(`Guardado #${id_producto}`, true);
|
||||
|
||||
// refrescar listado y reabrir seleccionado
|
||||
@@ -489,7 +489,7 @@
|
||||
// 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 data = await jget(`/api/v1/rpc/get_materia/${id}`); // { materia: {...}, proveedores: [...] }
|
||||
const m = data.materia || {};
|
||||
const provs = Array.isArray(data.proveedores) ? data.proveedores : [];
|
||||
|
||||
@@ -518,7 +518,7 @@
|
||||
};
|
||||
if (!payload.nombre) { mpToast('Nombre requerido'); return; }
|
||||
|
||||
const r = await jpost('/api/rpc/save_materia', payload); // => { id_mat_prima }
|
||||
const r = await jpost('/api/v1/rpc/save_materia', payload); // => { id_mat_prima }
|
||||
mpToast(`Guardado #${r.id_mat_prima}`, true);
|
||||
|
||||
// refrescar listas globales
|
||||
|
||||
@@ -339,7 +339,7 @@ function barsCompareSVG(a,b){ // a=ventas, b=gastos: arrays [{label,value}]
|
||||
let cacheAsist=[];
|
||||
|
||||
async function fetchAsistencias(desde,hasta){
|
||||
try { return await jpost('/api/rpc/report_asistencia', { desde, hasta }); }
|
||||
try { return await jpost('/api/v1/rpc/report_asistencia', { desde, hasta }); }
|
||||
catch { const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`; return await jget(url); }
|
||||
}
|
||||
function renderAsistTabla(rows){
|
||||
@@ -366,7 +366,7 @@ async function loadAsist(){
|
||||
}
|
||||
|
||||
$('#repStatus').textContent = 'Cargando asistencias…';
|
||||
cacheAsist = await jpost('/api/rpc/report_asistencia', { desde: d, hasta: h })
|
||||
cacheAsist = await jpost('/api/v1/rpc/report_asistencia', { desde: d, hasta: h })
|
||||
.catch(async ()=>{
|
||||
// fallback a tabla genérica si el RPC no está
|
||||
const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(d)}&hasta=${encodeURIComponent(h)}&limit=10000`;
|
||||
@@ -502,7 +502,7 @@ function getYearSafe(val){
|
||||
|
||||
async function fetchTickets(year){
|
||||
const y = getYearSafe(year);
|
||||
return await jpost('/api/rpc/report_tickets', { year: y });
|
||||
return await jpost('/api/v1/rpc/report_tickets', { year: y });
|
||||
}
|
||||
|
||||
function renderTickets(data){
|
||||
@@ -523,7 +523,7 @@ function renderTickets(data){
|
||||
let cacheGastos=null; // {year, months:[{mes,nombre,importe}], total, avg}
|
||||
async function fetchGastos(year){
|
||||
// 1) Intentar RPC
|
||||
try { return await jpost('/api/rpc/report_gastos', { year }); } catch {}
|
||||
try { return await jpost('/api/v1/rpc/report_gastos', { year }); } catch {}
|
||||
// 2) Fallback: traer compras y agrupar en el cliente
|
||||
const rows = await jget('/api/table/compras?limit=10000&order_by=fec_compra%20asc').catch(()=>[]);
|
||||
const months = Array.from({length:12},(_,i)=>({mes:i+1,nombre:monthNames[i],importe:0}));
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
|
||||
// RPC helpers
|
||||
async function rpcGet(doc, desde, hasta){
|
||||
const r = await fetch('/api/rpc/asistencia_get', {
|
||||
const r = await fetch('/api/v1/rpc/asistencia_get', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ doc, desde, hasta })
|
||||
});
|
||||
@@ -237,7 +237,7 @@
|
||||
return await r.json();
|
||||
}
|
||||
async function rpcUpdateRaw(id_raw, fecha, hora, modo){
|
||||
const r = await fetch('/api/rpc/asistencia_update_raw', {
|
||||
const r = await fetch('/api/v1/rpc/asistencia_update_raw', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ id_raw, fecha, hora, modo })
|
||||
});
|
||||
@@ -245,7 +245,7 @@
|
||||
return await r.json();
|
||||
}
|
||||
async function rpcDeleteRaw(id_raw){
|
||||
const r = await fetch('/api/rpc/asistencia_delete_raw', {
|
||||
const r = await fetch('/api/v1/rpc/asistencia_delete_raw', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ id_raw })
|
||||
});
|
||||
@@ -414,7 +414,7 @@
|
||||
}
|
||||
|
||||
async function importAsistencia(registros, origen){
|
||||
const r = await fetch('/api/rpc/import_asistencia', {
|
||||
const r = await fetch('/api/v1/rpc/import_asistencia', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ registros, origen })
|
||||
@@ -579,7 +579,7 @@
|
||||
|
||||
async function fetchNamesForDocs(docs){
|
||||
try {
|
||||
const r = await fetch('/api/rpc/find_usuarios_por_documentos', {
|
||||
const r = await fetch('/api/v1/rpc/find_usuarios_por_documentos', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ docs })
|
||||
|
||||
Reference in New Issue
Block a user