Compare commits

...

2 Commits

17 changed files with 1584 additions and 811 deletions

View File

@ -1,17 +1,18 @@
{
"name": "aplication",
"name": "workarround",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aplication",
"name": "workarround",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"chalk": "^5.6.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"pg": "^8.16.3",
@ -56,11 +57,16 @@
"node": ">= 8"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/binary-extensions": {
@ -345,6 +351,21 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@ -446,6 +467,36 @@
"resolved": "https://registry.npmjs.org/express-ejs-layouts/-/express-ejs-layouts-2.5.1.tgz",
"integrity": "sha512-IXROv9n3xKga7FowT06n1Qn927JR8ZWDn5Dc9CJQoiiaaDqbhW5PDmWShzbpAa2wjWT1vJqaIM1S6vJwwX11gA=="
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -732,6 +783,23 @@
"dev": true,
"license": "ISC"
},
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -1018,6 +1086,12 @@
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",

View File

@ -18,6 +18,7 @@
"chalk": "^5.6.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"pg": "^8.16.3",

View File

@ -12,349 +12,426 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Variables de Entorno
import dotenv, { config } from 'dotenv';
import dotenv from 'dotenv';
// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV
try {
if (process.env.NODE_ENV === 'development') {
dotenv.config({ path: path.resolve(__dirname, '../.env.development' )});
console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`);
} else if (process.env.NODE_ENV === 'stage') {
dotenv.config({ path: path.resolve(__dirname, '../.env.test' )});
console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`);
} else if (process.env.NODE_ENV === 'production') {
dotenv.config({ path: path.resolve(__dirname, '../.env.production' )});
console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`);
}
} catch (error) {
console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error);
// 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
}
// Renderiado
// ----------------------------------------------------------
// 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')));
// Configuración de conexión PostgreSQL
// ----------------------------------------------------------
// 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")));
// ----------------------------------------------------------
// 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
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
max: 10
};
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'
];
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 = "Inicio";
res.locals.pageId = "home";
res.render("estadoComandas");
});
// app.get('/', (req, res) => {
// res.sendFile(path.join(__dirname, 'pages', 'dashboard.html'));
// });
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'));
// });
// ----------------------------------------------------------
// 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 });
}
});
// Listado (con join) y totales por comanda
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)
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); }
});
// 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); }
});
// ----------------------------------------------------------
// 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(); // liberar el cliente de nuevo al pool
client.release();
} catch (error) {
console.error('Error al conectar con la base de datos al iniciar:', error.message);
console.error(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`);
console.error('Revisar credenciales y accesos de red.');
}
}
// ----------------------------------------------------------
// Inicio del servidor
// ----------------------------------------------------------
// === Servir páginas estáticas ===
app.get('/',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'index.html')));
app.get('/planes', async (req, res) => {
try {
const { rows: [row] } = await pool.query(
'SELECT api.get_planes_json($1) AS data;',
[true]
);
res.type('application/json').send(row.data);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al cargar planes' });
}
});
app.post('/api/registro', async (req, res) => {
const {
nombre_empresa,
rut,
correo,
telefono,
direccion,
logo,
clave_acceso,
plan_id
} = req.body;
try {
const client = await pool.connect();
// 1. Hashear la contraseña
const hash = await bcrypt.hash(clave_acceso, 10);
// 2. Insertar el tenant
const result = await client.query(`
INSERT INTO tenant (
nombre_empresa, rut, correo, telefono, direccion, logo,
clave_acceso, plan_id, nombre_base_datos
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, 'TEMPORAL'
)
RETURNING uuid;
`, [
nombre_empresa, rut, correo, telefono, direccion, logo,
hash, plan_id
]);
const uuid = result.rows[0].uuid;
const nombre_base_datos = `tenantdb_${uuid}`.replace(/-/g, '').substring(0, 24); // ajustamos para longitud segura
// 3. Actualizar el campo nombre_base_datos
await client.query(`
UPDATE tenant SET nombre_base_datos = $1 WHERE uuid = $2
`, [nombre_base_datos, uuid]);
client.release();
return res.status(201).json({
message: 'Tenant registrado correctamente',
uuid,
nombre_base_datos
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Error al registrar tenant' });
}
});
app.post('/api/login', async (req, res) => {
const { correo, clave_acceso } = req.body;
try {
const client = await pool.connect();
const result = await client.query(`
SELECT uuid, clave_acceso, nombre_empresa, nombre_base_datos
FROM tenant
WHERE correo = $1 AND estado = true
`, [correo]);
client.release();
if (result.rows.length === 0) {
return res.status(401).json({ error: 'Correo no registrado o inactivo' });
}
const tenant = result.rows[0];
const coincide = await bcrypt.compare(clave_acceso, tenant.clave_acceso);
if (!coincide) {
return res.status(401).json({ error: 'Clave incorrecta' });
}
return res.status(200).json({
message: 'Login correcto',
uuid: tenant.uuid,
nombre_empresa: tenant.nombre_empresa,
base_datos: tenant.nombre_base_datos
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Error al validar login' });
}
});
app.get('/roles', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'roles.html')));
app.get('/usuarios', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'usuarios.html')));
app.get('/categorias',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'categorias.html')));
app.get('/productos', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'productos.html')));
// Helper de consulta con acquire/release explícito
async function q(text, params) {
const client = await pool.connect();
try {
return await client.query(text, params);
} finally {
client.release();
}
}
// === API Roles ===
// GET: listar
app.get('/api/roles', async (req, res) => {
try {
const { rows } = await q('SELECT id_rol, nombre FROM roles ORDER BY id_rol ASC');
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar roles' });
}
});
// POST: crear
app.post('/api/roles', async (req, res) => {
try {
const { nombre } = req.body;
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
const { rows } = await q(
'INSERT INTO roles (nombre) VALUES ($1) RETURNING id_rol, nombre',
[nombre.trim()]
);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
// Manejo de único/duplicado
if (e.code === '23505') return res.status(409).json({ error: 'El rol ya existe' });
res.status(500).json({ error: 'No se pudo crear el rol' });
}
});
// === API Usuarios ===
// GET: listar
app.get('/api/usuarios', async (req, res) => {
try {
const { rows } = await q(`
SELECT id_usuario, documento, img_perfil, nombre, apellido, correo, telefono, fec_nacimiento, activo
FROM usuarios
ORDER BY id_usuario ASC
`);
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar usuarios' });
}
});
// POST: crear
app.post('/api/usuarios', async (req, res) => {
try {
const { documento, nombre, apellido, correo, telefono, fec_nacimiento } = req.body;
if (!nombre || !apellido) return res.status(400).json({ error: 'Nombre y apellido requeridos' });
const { rows } = await q(`
INSERT INTO usuarios (documento, nombre, apellido, correo, telefono, fec_nacimiento)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id_usuario, documento, nombre, apellido, correo, telefono, fec_nacimiento, activo
`, [
documento || null,
nombre.trim(),
apellido.trim(),
correo || null,
telefono || null,
fec_nacimiento || null
]);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
if (e.code === '23505') return res.status(409).json({ error: 'Documento/Correo/Teléfono ya existe' });
res.status(500).json({ error: 'No se pudo crear el usuario' });
}
});
// === API Categorías ===
// GET: listar
app.get('/api/categorias', async (req, res) => {
try {
const { rows } = await q('SELECT id_categoria, nombre, visible FROM categorias ORDER BY id_categoria ASC');
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar categorías' });
}
});
// POST: crear
app.post('/api/categorias', async (req, res) => {
try {
const { nombre, visible } = req.body;
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
const vis = (typeof visible === 'boolean') ? visible : true;
const { rows } = await q(`
INSERT INTO categorias (nombre, visible)
VALUES ($1, $2)
RETURNING id_categoria, nombre, visible
`, [nombre.trim(), vis]);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
if (e.code === '23505') return res.status(409).json({ error: 'La categoría ya existe' });
res.status(500).json({ error: 'No se pudo crear la categoría' });
}
});
// === API Productos ===
// GET: listar
app.get('/api/productos', async (req, res) => {
try {
const { rows } = await q(`
SELECT id_producto, nombre, img_producto, precio, activo, id_categoria
FROM productos
ORDER BY id_producto ASC
`);
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar productos' });
}
});
// POST: crear
app.post('/api/productos', async (req, res) => {
try {
let { nombre, id_categoria, precio } = req.body;
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
id_categoria = parseInt(id_categoria, 10);
precio = parseFloat(precio);
if (!Number.isInteger(id_categoria)) return res.status(400).json({ error: 'id_categoria inválido' });
if (!(precio >= 0)) return res.status(400).json({ error: 'precio inválido' });
const { rows } = await q(`
INSERT INTO productos (nombre, id_categoria, precio)
VALUES ($1, $2, $3)
RETURNING id_producto, nombre, precio, activo, id_categoria
`, [nombre.trim(), id_categoria, precio]);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
// FK categories / checks
if (e.code === '23503') return res.status(400).json({ error: 'La categoría no existe' });
res.status(500).json({ error: 'No se pudo crear el producto' });
}
});
// Colores personalizados
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);
// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`);
app.use(expressLayouts);
// Iniciar servidor
app.listen( process.env.PORT, () => {
console.log(`Servidor de ${chalk.red('aplicación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` );
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`));
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();
});
app.get("/health", async (req, res) => {
// Podés chequear DB aquí. 200 = healthy; 503 = not ready.
res.status(200).json({ status: "ok" });
});
// Healthcheck
app.get('/health', async (_req, res) => {
res.status(200).json({ status: 'ok' });
});

View File

@ -1,70 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Categorías</title>
</head>
<body>
<h1>Categorías</h1>
<h2>Crear categoría</h2>
<form id="form-categoria">
<label>Nombre:
<input type="text" name="nombre" required />
</label>
<label>Visible:
<select name="visible">
<option value="true" selected></option>
<option value="false">No</option>
</select>
</label>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead><tr><th>ID</th><th>Nombre</th><th>Visible</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/categorias';
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(c => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${c.id_categoria}</td><td>${c.nombre}</td><td>${c.visible ? 'Sí' : 'No'}</td>`;
tbody.appendChild(tr);
});
}
document.getElementById('form-categoria').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const nombre = fd.get('nombre').trim();
const visible = fd.get('visible') === 'true';
if (!nombre) return;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nombre, visible })
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
listar();
</script>
</body>
</html>

View File

@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Comandas</title>
<style>
:root { --gap: 12px; --radius: 10px; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
header h1 { margin:0; font-size:16px; font-weight:600; }
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 1.15fr 0.85fr; gap: var(--gap); }
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
.card .bd { padding:14px; }
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
.grid { display:grid; gap:10px; }
.grid.cols-2 { grid-template-columns: 1fr 1fr; }
.muted { color:#666; }
select, input, textarea, button { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
select:focus, input:focus, textarea:focus { outline:none; border-color:#999; }
input[type="number"] { width: 100%; }
textarea { width:100%; min-height: 68px; resize: vertical; }
button { cursor: pointer; }
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; }
.btn.primary { background:#111; border-color:#111; color:#fff; }
.btn.ghost { background:#fff; }
.btn.small { padding:6px 8px; font-size: 13px; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
.toolbar { display:flex; gap:10px; align-items:center; }
.spacer { flex:1 }
.search { display:flex; gap:8px; }
.search input { flex:1; }
table { width:100%; border-collapse: collapse; }
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
th, td { padding:8px 10px; border-bottom:1px solid #eee; vertical-align: middle; }
.qty { display:flex; align-items:center; gap:6px; }
.qty input { width: 90px; }
.right { text-align:right; }
.total { font-size: 22px; font-weight: 700; }
.notice { padding:10px; border-radius:8px; border:1px solid #e7e7ef; background:#fafafa; }
.ok { color:#0a7d28; }
.err { color:#b00020; }
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
.kpi { display:flex; gap:6px; align-items: baseline; }
</style>
</head>
<body>
<header>
<h1>📋 Nueva Comanda</h1>
<div class="spacer"></div>
<span class="pill muted">/api/*</span>
</header>
<main>
<!-- Panel izquierdo: productos -->
<section class="card" id="panelProductos">
<div class="hd">
<strong>Productos</strong>
<div class="spacer"></div>
<div class="toolbar">
<span class="muted" id="prodCount">0 ítems</span>
</div>
</div>
<div class="bd">
<div class="row search" style="margin-bottom:10px;">
<input id="busqueda" type="search" placeholder="Buscar por nombre o categoría…"/>
<button class="btn" id="limpiarBusqueda">Limpiar</button>
</div>
<div id="listadoProductos" style="max-height: 58vh; overflow:auto;">
<!-- tabla productos -->
</div>
</div>
</section>
<!-- Panel derecho: datos + carrito + crear -->
<section class="card" id="panelComanda">
<div class="hd"><strong>Detalles</strong></div>
<div class="bd grid" style="gap:14px;">
<div class="grid cols-2">
<div>
<label class="muted">Mesa</label>
<select id="selMesa"></select>
</div>
<div>
<label class="muted">Usuario</label>
<select id="selUsuario"></select>
</div>
</div>
<div>
<label class="muted">Observaciones</label>
<textarea id="obs"></textarea>
</div>
<div class="notice muted">La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.</div>
<div class="card">
<div class="hd"><strong>Carrito</strong></div>
<div class="bd" id="carritoWrap">
<div class="muted">Aún no agregaste productos.</div>
</div>
<div class="sticky-footer">
<div class="kpi"><span class="muted">Ítems:</span><strong id="kpiItems">0</strong></div>
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
<div class="spacer"></div>
<button class="btn ghost" id="vaciar">Vaciar</button>
<button class="btn primary" id="crear">Crear Comanda</button>
</div>
</div>
<div id="msg" class="muted"></div>
</div>
</section>
</main>
<script>
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const state = {
productos: [],
mesas: [],
usuarios: [],
carrito: [], // [{id_producto, nombre, pre_unitario, cantidad}]
filtro: ''
};
// ---------- Utils ----------
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => { const el = $('#msg'); el.className = ok ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
async function jget(url) {
const res = await fetch(url);
let data; try { data = await res.json(); } catch { data = null; }
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body) {
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ---------- Load data ----------
async function init() {
// productos, mesas, usuarios
const [prods, mesas, usuarios] = await Promise.all([
jget('/api/table/productos?limit=1000'),
jget('/api/table/mesas?limit=1000'),
jget('/api/table/usuarios?limit=1000')
]);
state.productos = prods.filter(p => p.activo !== false); // si existe activo=false, filtrarlo
state.mesas = mesas;
state.usuarios = usuarios.filter(u => u.activo !== false);
hydrateMesas();
hydrateUsuarios();
renderProductos();
renderCarrito();
$('#busqueda').addEventListener('input', () => { state.filtro = $('#busqueda').value.trim().toLowerCase(); renderProductos(); });
$('#limpiarBusqueda').addEventListener('click', () => { $('#busqueda').value=''; state.filtro=''; renderProductos(); });
$('#vaciar').addEventListener('click', () => { state.carrito=[]; renderCarrito(); });
$('#crear').addEventListener('click', crearComanda);
}
function hydrateMesas() {
const sel = $('#selMesa'); sel.innerHTML = '';
for (const m of state.mesas) {
const o = document.createElement('option');
o.value = m.id_mesa;
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
sel.appendChild(o);
}
}
function hydrateUsuarios() {
const sel = $('#selUsuario'); sel.innerHTML = '';
for (const u of state.usuarios) {
const o = document.createElement('option');
o.value = u.id_usuario;
o.textContent = `${u.nombre} ${u.apellido}`.trim();
sel.appendChild(o);
}
}
// ---------- Productos ----------
function renderProductos() {
let rows = state.productos.slice();
if (state.filtro) {
rows = rows.filter(p =>
(p.nombre || '').toLowerCase().includes(state.filtro) ||
String(p.id_categoria ?? '').includes(state.filtro)
);
}
$('#prodCount').textContent = `${rows.length} ítems`;
if (!rows.length) {
$('#listadoProductos').innerHTML = '<div class="muted">Sin resultados.</div>';
return;
}
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>Producto</th>
<th class="right">Precio</th>
<th style="width:180px;">Cantidad</th>
<th style="width:90px;"></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
for (const p of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${p.nombre}</td>
<td class="right">${money(p.precio)}</td>
<td>
<div class="qty">
<input type="number" min="0.001" step="0.001" value="1.000" data-qty />
<button class="btn small" data-dec>-</button>
<button class="btn small" data-inc>+</button>
</div>
</td>
<td><button class="btn primary small" data-add>Agregar</button></td>
`;
const qty = tr.querySelector('[data-qty]');
tr.querySelector('[data-dec]').addEventListener('click', () => { qty.value = Math.max(0.001, (parseFloat(qty.value||'0') - 1)).toFixed(3); });
tr.querySelector('[data-inc]').addEventListener('click', () => { qty.value = (parseFloat(qty.value||'0') + 1).toFixed(3); });
tr.querySelector('[data-add]').addEventListener('click', () => addToCart(p, parseFloat(qty.value||'1')) );
tb.appendChild(tr);
}
$('#listadoProductos').innerHTML = '';
$('#listadoProductos').appendChild(tbl);
}
function addToCart(prod, cantidad) {
if (!(cantidad > 0)) { toast('Cantidad inválida'); return; }
const precio = parseFloat(prod.precio);
const it = state.carrito.find(i => i.id_producto === prod.id_producto && i.pre_unitario === precio);
if (it) it.cantidad = Number((it.cantidad + cantidad).toFixed(3));
else state.carrito.push({ id_producto: prod.id_producto, nombre: prod.nombre, pre_unitario: precio, cantidad: Number(cantidad.toFixed(3)) });
renderCarrito();
}
// ---------- Carrito ----------
function renderCarrito() {
const wrap = $('#carritoWrap');
if (!state.carrito.length) { wrap.innerHTML = '<div class="muted">Aún no agregaste productos.</div>'; $('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0); return; }
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>Producto</th>
<th class="right">Unitario</th>
<th class="right">Cantidad</th>
<th class="right">Subtotal</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let items = 0, total = 0;
state.carrito.forEach((it, idx) => {
items += 1;
const sub = Number(it.pre_unitario) * Number(it.cantidad);
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${it.nombre}</td>
<td class="right">${money(it.pre_unitario)}</td>
<td class="right">
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" style="width:120px"/>
</td>
<td class="right">${money(sub)}</td>
<td class="right">
<button class="btn small" data-del>Quitar</button>
</td>
`;
const qty = tr.querySelector('input[type="number"]');
qty.addEventListener('change', () => {
const v = parseFloat(qty.value||'0');
if (!(v>0)) { toast('Cantidad inválida'); qty.value = it.cantidad.toFixed(3); return; }
it.cantidad = Number(v.toFixed(3));
renderCarrito();
});
tr.querySelector('[data-del]').addEventListener('click', () => {
state.carrito.splice(idx,1);
renderCarrito();
});
tb.appendChild(tr);
});
wrap.innerHTML = '';
wrap.appendChild(tbl);
$('#kpiItems').textContent = String(items);
$('#kpiTotal').textContent = money(total);
}
// ---------- Crear comanda ----------
async function crearComanda() {
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
const id_mesa = parseInt($('#selMesa').value, 10);
const id_usuario = parseInt($('#selUsuario').value, 10);
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
const observaciones = $('#obs').value.trim() || null;
try {
// 1) encabezado comanda (estado por defecto: 'abierta'; fecha la pone la DB)
const { inserted: com } = await jpost('/api/table/comandas', {
id_usuario,
id_mesa,
estado: 'abierta',
observaciones
});
// 2) detalle (una inserción por renglón)
const id_comanda = com.id_comanda;
const payloads = state.carrito.map(it => ({
id_comanda,
id_producto: it.id_producto,
cantidad: it.cantidad,
pre_unitario: it.pre_unitario
}));
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
state.carrito = [];
renderCarrito();
$('#obs').value = '';
toast(`Comanda #${id_comanda} creada`, true);
} catch (e) {
toast(e.message || 'No se pudo crear la comanda');
}
}
// GO
init().catch(err => toast(err.message || 'Error cargando datos'));
</script>
</body>
</html>

View File

@ -0,0 +1,293 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Dashboard</title>
<style>
:root { --radius: 10px; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji'; background:#f6f7fb; color:#111; }
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e8e8ef; padding:16px 20px; display:flex; gap:12px; align-items:center; z-index:1;}
header h1 { margin:0; font-size:18px; font-weight:600;}
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
.card { background:#fff; border:1px solid #e8e8ef; border-radius: var(--radius); padding:16px; }
.row { display:flex; gap:16px; align-items: center; flex-wrap:wrap; }
select, input, button, textarea { font: inherit; padding:10px; border-radius:8px; border:1px solid #d7d7e0; background:#fff; }
select:focus, input:focus, textarea:focus { outline: none; border-color:#888; }
button { cursor:pointer; }
button.primary { background:#111; color:#fff; border-color:#111; }
table { width:100%; border-collapse: collapse; }
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
th, td { padding:10px 12px; border-bottom: 1px solid #eee; vertical-align: top; }
.muted { color:#666; }
.tabs { display:flex; gap:6px; margin-top:12px; }
.tab { padding:8px 10px; border:1px solid #e0e0ea; border-bottom:none; background:#fafafa; border-top-left-radius:8px; border-top-right-radius:8px; cursor:pointer; font-size:14px; }
.tab.active { background:#fff; border-color:#e0e0ea; }
.panel { border:1px solid #e0e0ea; border-radius: 0 8px 8px 8px; padding:16px; background:#fff; }
.grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
.help { font-size:12px; color:#777; margin-top:6px; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
.toolbar { display:flex; gap:10px; align-items:center; }
.spacer { flex:1 }
.error { color:#b00020; }
.success { color:#0a7d28; }
.sr-only{ position:absolute; width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
details summary { cursor:pointer; }
</style>
</head>
<body>
<header>
<h1>Dashboard</h1>
<div class="spacer"></div>
<span class="pill muted">/api/*</span>
</header>
<main class="card">
<div class="row" style="margin-bottom:12px;">
<label for="tableSelect" class="sr-only">Tabla</label>
<select id="tableSelect"></select>
<div class="spacer"></div>
<div class="toolbar">
<button id="refreshBtn">Recargar</button>
<span id="status" class="muted"></span>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="datos">Datos</button>
<button class="tab" data-tab="nuevo">Nuevo</button>
<button class="tab" data-tab="esquema">Esquema</button>
</div>
<section class="panel" id="panel-datos">
<div class="help">Mostrando hasta <span id="limitInfo">100</span> filas.</div>
<div id="tableContainer" style="overflow:auto;"></div>
</section>
<section class="panel" id="panel-nuevo" hidden>
<form id="insertForm" class="grid"></form>
<div class="row" style="margin-top:10px;">
<div class="spacer"></div>
<button id="insertBtn" class="primary">Insertar</button>
</div>
<div id="insertMsg" class="help"></div>
</section>
<section class="panel" id="panel-esquema" hidden>
<pre id="schemaPre" style="white-space:pre-wrap;"></pre>
</section>
<details style="margin-top:16px;">
<summary>Endpoints</summary>
<div class="help">GET /api/tables • GET /api/schema/:tabla • GET /api/table/:tabla?limit=100 • POST /api/table/:tabla</div>
</details>
</main>
<script>
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const state = { tables: [], table: null, schema: null, limit: 100 };
// Tabs
$$('.tab').forEach(t => t.addEventListener('click', () => {
$$('.tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
const tab = t.dataset.tab;
$('#panel-datos').hidden = tab !== 'datos';
$('#panel-nuevo').hidden = tab !== 'nuevo';
$('#panel-esquema').hidden = tab !== 'esquema';
}));
$('#refreshBtn').addEventListener('click', () => {
if (state.table) {
loadSchema(state.table);
loadData(state.table);
}
});
async function init() {
setStatus('Cargando tablas…');
const res = await fetch('/api/tables');
const tables = await res.json();
state.tables = tables;
const sel = $('#tableSelect');
sel.innerHTML = '';
tables.forEach(name => {
const o = document.createElement('option');
o.value = name; o.textContent = name;
sel.appendChild(o);
});
sel.addEventListener('change', () => selectTable(sel.value));
if (tables.length) {
selectTable(tables[0]);
} else {
setStatus('No hay tablas disponibles.');
}
}
async function selectTable(tbl) {
state.table = tbl;
await loadSchema(tbl);
await loadData(tbl);
buildForm();
}
async function loadSchema(tbl) {
const res = await fetch(`/api/schema/${tbl}`);
state.schema = await res.json();
$('#schemaPre').textContent = JSON.stringify(state.schema, null, 2);
}
async function loadData(tbl) {
setStatus('Cargando datos…');
const res = await fetch(`/api/table/${tbl}?limit=${state.limit}`);
const data = await res.json();
$('#limitInfo').textContent = String(state.limit);
renderTable(data);
clearStatus();
}
function renderTable(rows) {
const c = $('#tableContainer');
c.innerHTML = '';
if (!rows.length) { c.innerHTML = '<div class="muted">Sin datos.</div>'; return; }
const headers = Object.keys(rows[0]);
const table = document.createElement('table');
table.innerHTML = `
<thead><tr>${headers.map(h => '<th>'+h+'</th>').join('')}</tr></thead>
<tbody></tbody>
`;
const tbody = table.querySelector('tbody');
for (const row of rows) {
const tr = document.createElement('tr');
tr.innerHTML = headers.map(h => '<td>'+formatCell(row[h])+'</td>').join('');
tbody.appendChild(tr);
}
c.appendChild(table);
}
function formatCell(v) {
if (v === null || v === undefined) return '<span class="muted">NULL</span>';
if (typeof v === 'boolean') return v ? '✓' : '—';
if (typeof v === 'string' && v.match(/^\\d{4}-\\d{2}-\\d{2}/)) return new Date(v).toLocaleString();
return String(v);
}
function buildForm() {
const form = $('#insertForm');
form.innerHTML = '';
if (!state.schema) return;
for (const col of state.schema.columns) {
// Omitir PK auto y columnas generadas
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
const wrap = document.createElement('div');
const id = 'f_'+col.column_name;
wrap.innerHTML = `
<label for="${id}" class="muted">${col.column_name} <span class="muted">${col.data_type}</span> ${col.is_nullable ? '' : '<span class="pill">requerido</span>'}</label>
<div data-input></div>
${col.column_default ? '<div class="help">DEFAULT: '+col.column_default+'</div>' : ''}
`;
const holder = wrap.querySelector('[data-input]');
if (col.foreign) {
const sel = document.createElement('select');
sel.id = id;
holder.appendChild(sel);
hydrateOptions(sel, state.schema.table, col.column_name);
} else if (col.data_type.includes('boolean')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'checkbox';
holder.appendChild(inp);
} else if (col.data_type.includes('timestamp')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'datetime-local'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else if (col.data_type.includes('date')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'date'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else if (col.data_type.match(/numeric|integer|real|double/)) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'number'; inp.step = 'any'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else if (col.data_type.includes('text') || col.data_type.includes('character')) {
const inp = document.createElement('input');
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
} else {
const inp = document.createElement('input');
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
holder.appendChild(inp);
}
form.appendChild(wrap);
}
}
async function hydrateOptions(selectEl, table, column) {
selectEl.innerHTML = '<option value="">Cargando…</option>';
const res = await fetch(`/api/options/${table}/${column}`);
const opts = await res.json();
selectEl.innerHTML = '<option value="">Seleccione…</option>' + opts.map(o => `<option value="${o.id}">${o.label}</option>`).join('');
}
$('#insertBtn').addEventListener('click', async (e) => {
e.preventDefault();
if (!state.table) return;
const payload = {};
for (const col of state.schema.columns) {
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
const id = 'f_'+col.column_name;
const el = document.getElementById(id);
if (!el) continue;
let val = null;
if (el.type === 'checkbox') {
val = el.checked;
} else if (el.type === 'datetime-local' && el.value) {
// Convertir a ISO
val = new Date(el.value).toISOString().slice(0,19).replace('T',' ');
} else if (el.tagName === 'SELECT') {
val = el.value ? (isNaN(el.value) ? el.value : Number(el.value)) : null;
} else if (el.type === 'number') {
val = el.value === '' ? null : Number(el.value);
} else {
val = el.value === '' ? null : el.value;
}
if (val === null && !col.is_nullable && !col.column_default) {
showInsertMsg('Completa: '+col.column_name, true);
return;
}
if (val !== null) payload[col.column_name] = val;
}
try {
const res = await fetch(`/api/table/${state.table}`, {
method: 'POST',
headers: { 'Content-Type':'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error');
showInsertMsg('Insertado correctamente (id: '+(data.inserted?.id || '?')+')', false);
// Reset form
$('#insertForm').reset?.();
await loadData(state.table);
} catch (e) {
showInsertMsg(e.message, true);
}
});
function showInsertMsg(msg, isError=false) {
const m = $('#insertMsg');
m.className = 'help ' + (isError ? 'error' : 'success');
m.textContent = msg;
}
function setStatus(text) { $('#status').textContent = text; }
function clearStatus() { setStatus(''); }
// Start
init();
</script>
</body>
</html>

View File

@ -0,0 +1,280 @@
<!-- pages/estadoComandas.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Estado de Comandas</title>
<style>
:root { --gap: 12px; --radius: 10px; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
header h1 { margin:0; font-size:16px; font-weight:600; }
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 0.9fr 1.1fr; gap: var(--gap); }
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
.card .bd { padding:14px; }
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
.grid { display:grid; gap:10px; }
.muted { color:#666; }
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
.list { max-height: 70vh; overflow:auto; }
.list table { width:100%; border-collapse: collapse; }
.list th, .list td { padding:8px 10px; border-bottom:1px solid #eee; }
.list thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
.right { text-align:right; }
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; cursor:pointer; }
.btn.primary { background:#111; color:#fff; border-color:#111; }
.btn.danger { background:#b00020; color:#fff; border-color:#b00020; }
.btn.small { font-size: 13px; padding:6px 8px; }
select, input, textarea { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
.kpi { display:flex; gap:6px; align-items: baseline; }
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
.ok { color:#0a7d28; }
.err { color:#b00020; }
.tag { font-size:12px; padding:2px 8px; border-radius:6px; border:1px solid #e7e7ef; background:#fafafa; }
.tag.abierta { border-color:#0a7d28; color:#0a7d28; }
.tag.cerrada { border-color:#555; color:#555; }
.tag.pagada { border-color:#1b5e20; color:#1b5e20; }
.tag.anulada { border-color:#b00020; color:#b00020; }
table { width:100%; border-collapse: collapse; }
th, td { padding:8px 10px; border-bottom:1px solid #eee; }
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
</style>
</head>
<body>
<header>
<h1>🧾 Estado de Comandas</h1>
<div style="flex:1"></div>
<a class="pill" href="/comandas"> Nueva comanda</a>
</header>
<main>
<!-- Izquierda: listado -->
<section class="card">
<div class="hd">
<strong>Listado</strong>
<div style="flex:1"></div>
<label class="muted" style="display:flex; gap:6px; align-items:center;">
<input id="soloAbiertas" type="checkbox" checked />
Solo abiertas
</label>
</div>
<div class="bd">
<div class="row" style="margin-bottom:10px;">
<input id="buscar" type="search" placeholder="Buscar por #, mesa o usuario…" style="flex:1"/>
<button class="btn" id="limpiar">Limpiar</button>
</div>
<div class="list" id="lista"></div>
</div>
</section>
<!-- Derecha: detalle -->
<section class="card">
<div class="hd">
<strong>Detalle</strong>
<div style="flex:1"></div>
<span id="detalleEstado" class="tag"></span>
</div>
<div class="bd" id="detalle">
<div class="muted">Selecciona una comanda para ver el detalle.</div>
</div>
<div class="sticky-footer">
<div class="kpi"><span class="muted">ID:</span><strong id="kpiId"></strong></div>
<div class="kpi" style="margin-left:8px;"><span class="muted">Mesa:</span><strong id="kpiMesa"></strong></div>
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
<div style="flex:1"></div>
<button class="btn" id="reabrir">Reabrir</button>
<button class="btn primary" id="cerrar">Cerrar</button>
</div>
<div class="bd">
<div id="msg" class="muted"></div>
</div>
</section>
</main>
<script>
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const state = {
filtro: '',
soloAbiertas: true,
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, observaciones }]
sel: null, // id seleccionado
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
};
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => { const el = $('#msg'); el.className = ok ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
async function jget(url) {
const res = await fetch(url);
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body) {
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ----------- Data -----------
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
const rows = await jget(url);
state.lista = rows;
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/comandas/${id}/detalle`);
state.detalle = det;
renderDetalle();
}
// ----------- UI: Lista -----------
function renderLista() {
let rows = state.lista.slice();
const f = state.filtro;
if (f) {
const k = f.toLowerCase();
rows = rows.filter(r =>
String(r.id_comanda).includes(k) ||
(String(r.mesa_numero ?? '').includes(k)) ||
((`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(k))
);
}
const box = $('#lista');
if (!rows.length) { box.innerHTML = '<div class="muted">Sin resultados.</div>'; return; }
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>#</th>
<th>Mesa</th>
<th>Usuario</th>
<th>Fecha</th>
<th>Estado</th>
<th class="right">Items</th>
<th class="right">Total</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
rows.forEach(r => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${r.id_comanda}</td>
<td>#${r.mesa_numero} · ${r.mesa_apodo || ''}</td>
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
<td>${new Date(r.fec_creacion).toLocaleString()}</td>
<td><span class="tag ${r.estado}">${r.estado}</span></td>
<td class="right">${r.items ?? '—'}</td>
<td class="right">${money(r.total ?? 0)}</td>
`;
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
}
// ----------- UI: Detalle -----------
function applyHeader(r) {
$('#kpiId').textContent = r.id_comanda ?? '—';
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
$('#detalleEstado').className = `tag ${r.estado}`;
$('#detalleEstado').textContent = r.estado;
$('#kpiTotal').textContent = money(r.total ?? 0);
// Botones según estado
const cerr = $('#cerrar'), reab = $('#reabrir');
if (r.estado === 'abierta') {
cerr.disabled = false; cerr.title = '';
reab.disabled = true; reab.title = 'Ya está abierta';
} else {
cerr.disabled = false; // permitir cerrar (idempotente/override)
reab.disabled = false;
}
}
function renderDetalle() {
const box = $('#detalle');
if (!state.detalle.length) { box.innerHTML = '<div class="muted">Sin detalle.</div>'; return; }
const tbl = document.createElement('table');
tbl.innerHTML = `
<thead>
<tr>
<th>Producto</th>
<th class="right">Unitario</th>
<th class="right">Cantidad</th>
<th class="right">Subtotal</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let total = 0;
state.detalle.forEach(r => {
total += Number(r.subtotal||0);
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.producto_nombre}</td>
<td class="right">${money(r.pre_unitario)}</td>
<td class="right">${Number(r.cantidad).toFixed(3)}</td>
<td class="right">${money(r.subtotal)}</td>
<td>${r.observaciones||''}</td>
`;
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
$('#kpiTotal').textContent = money(total);
}
// ----------- Actions -----------
async function setEstado(estado) {
if (!state.sel) return;
try {
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
await loadLista();
// mantener seleccionada si sigue existiendo en filtro
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
if (found) { applyHeader(found); await loadDetalle(found.id_comanda); }
else {
state.sel = null;
$('#detalle').innerHTML = '<div class="muted">Selecciona una comanda para ver el detalle.</div>';
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'tag';
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
}
} catch (e) {
toast(e.message || 'No se pudo cambiar el estado');
}
}
// ----------- Init -----------
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
$('#cerrar').addEventListener('click', () => setEstado('cerrada'));
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
</script>
</body>
</html>

View File

@ -1,154 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>SuiteCoffee - Autenticación</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light d-flex justify-content-center align-items-center vh-100">
<div class="card shadow p-4" style="width: 100%; max-width: 400px;">
<h4 class="text-center mb-3" id="form-title">Iniciar Sesión</h4>
<!-- Mensajes -->
<div id="mensaje" class="alert d-none" role="alert"></div>
<!-- Formulario compartido -->
<form id="formulario">
<div id="registro-extra" style="display: none;">
<div class="mb-2">
<input type="text" class="form-control" id="nombre_empresa" placeholder="Nombre de la empresa" required>
</div>
<div class="mb-2">
<input type="text" class="form-control" id="rut" placeholder="RUT (opcional)" required>
</div>
<div class="mb-2">
<input type="text" class="form-control" id="telefono" placeholder="Teléfono">
</div>
<div class="mb-2">
<input type="text" class="form-control" id="direccion" placeholder="Dirección">
</div>
<div class="mb-2">
<input type="text" class="form-control" id="logo" placeholder="Logo URL (opcional)">
</div>
<div class="mb-2">
<select class="form-select" id="plan_id" required>
<option value="">Cargando planes...</option>
</select>
</div>
</div>
<div class="mb-2">
<input type="email" class="form-control" id="correo" placeholder="Correo" required>
</div>
<div class="mb-3">
<input type="password" class="form-control" id="clave" placeholder="Contraseña" required>
</div>
<button type="submit" class="btn btn-primary w-100" id="btn-submit">Entrar</button>
</form>
<div class="text-center mt-3">
<button class="btn btn-link btn-sm" id="toggle-mode">¿No tienes cuenta? Regístrate</button>
</div>
</div>
<script>
const form = document.getElementById('formulario');
const mensaje = document.getElementById('mensaje');
const toggleModeBtn = document.getElementById('toggle-mode');
const registroExtra = document.getElementById('registro-extra');
const formTitle = document.getElementById('form-title');
const btnSubmit = document.getElementById('btn-submit');
let modoRegistro = false;
toggleModeBtn.addEventListener('click', () => {
modoRegistro = !modoRegistro;
registroExtra.style.display = modoRegistro ? 'block' : 'none';
formTitle.textContent = modoRegistro ? 'Registrar Cuenta' : 'Iniciar Sesión';
btnSubmit.textContent = modoRegistro ? 'Registrarse' : 'Entrar';
toggleModeBtn.textContent = modoRegistro ? '¿Ya tienes cuenta? Inicia sesión' : '¿No tienes cuenta? Regístrate';
if (modoRegistro) {
cargarPlanes(); // ✅ ahora sí se ejecutará correctamente
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
mensaje.classList.add('d-none');
const datos = {
correo: document.getElementById('correo').value,
clave_acceso: document.getElementById('clave').value
};
if (modoRegistro) {
Object.assign(datos, {
nombre_empresa: document.getElementById('nombre_empresa').value,
rut: document.getElementById('rut').value,
telefono: document.getElementById('telefono').value,
direccion: document.getElementById('direccion').value,
logo: document.getElementById('logo').value,
plan_id: document.getElementById('plan_id').value
});
}
try {
const url = modoRegistro ? '/api/registro' : '/api/login';
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos)
});
const resultado = await res.json();
if (!res.ok) {
throw new Error(resultado.error || 'Error inesperado');
}
mensaje.className = 'alert alert-success';
mensaje.textContent = resultado.message || (modoRegistro ? 'Registro exitoso' : 'Inicio exitoso');
mensaje.classList.remove('d-none');
if (!modoRegistro) {
// Redirigir a dashboard, por ejemplo
// window.location.href = `/dashboard?tenant=${resultado.uuid}`;
}
} catch (err) {
mensaje.className = 'alert alert-danger';
mensaje.textContent = err.message;
mensaje.classList.remove('d-none');
}
});
// ✅ Ahora la función está declarada correctamente
async function cargarPlanes() {
const select = document.getElementById('plan_id');
select.innerHTML = '<option value="">Cargando planes...</option>';
try {
const res = await fetch('/planes');
const planes = await res.json();
select.innerHTML = '<option value="">Seleccione un plan</option>';
planes.forEach(plan => {
const opt = document.createElement('option');
opt.value = plan.id;
opt.textContent = plan.nombre.charAt(0).toUpperCase() + plan.nombre.slice(1);
select.appendChild(opt);
});
} catch (err) {
select.innerHTML = '<option value="">Error al cargar planes</option>';
console.error('Error cargando planes:', err);
}
}
</script>
</body>
</html>

View File

@ -1,106 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Productos</title>
</head>
<body>
<h1>Productos</h1>
<h2>Crear producto</h2>
<form id="form-producto">
<div>
<label>Nombre:
<input name="nombre" type="text" required />
</label>
</div>
<div>
<label>Precio:
<input name="precio" type="number" step="0.01" min="0" required />
</label>
</div>
<div>
<label>Categoría:
<select name="id_categoria" id="sel-categoria" required></select>
</label>
</div>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead>
<tr><th>ID</th><th>Nombre</th><th>Precio</th><th>Activo</th><th>ID Categoría</th></tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/productos';
const API_CAT = '/api/categorias';
async function cargarCategorias() {
const res = await fetch(API_CAT);
const data = await res.json();
const sel = document.getElementById('sel-categoria');
sel.innerHTML = '<option value="" disabled selected>Seleccione...</option>';
data.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id_categoria;
opt.textContent = `${c.id_categoria} - ${c.nombre}`;
sel.appendChild(opt);
});
}
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(p => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${p.id_producto}</td>
<td>${p.nombre}</td>
<td>${Number(p.precio).toFixed(2)}</td>
<td>${p.activo ? 'Sí' : 'No'}</td>
<td>${p.id_categoria}</td>
`;
tbody.appendChild(tr);
});
}
document.getElementById('form-producto').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = {
nombre: fd.get('nombre').trim(),
precio: parseFloat(fd.get('precio')),
id_categoria: parseInt(fd.get('id_categoria'), 10)
};
if (!payload.nombre || isNaN(payload.precio) || isNaN(payload.id_categoria)) return;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
(async () => {
await cargarCategorias();
await listar();
})();
</script>
</body>
</html>

View File

@ -1,62 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Roles</title>
</head>
<body>
<h1>Roles</h1>
<h2>Crear rol</h2>
<form id="form-rol">
<label>Nombre del rol:
<input type="text" name="nombre" required />
</label>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead><tr><th>ID</th><th>Nombre</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/roles';
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(r => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${r.id_rol}</td><td>${r.nombre}</td>`;
tbody.appendChild(tr);
});
}
document.getElementById('form-rol').addEventListener('submit', async (e) => {
e.preventDefault();
const nombre = e.target.nombre.value.trim();
if (!nombre) return;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nombre })
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
listar();
</script>
</body>
</html>

View File

@ -1,104 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Usuarios</title>
</head>
<body>
<h1>Usuarios</h1>
<h2>Crear usuario</h2>
<form id="form-usuario">
<div>
<label>Documento:
<input name="documento" type="text" />
</label>
</div>
<div>
<label>Nombre:
<input name="nombre" type="text" required />
</label>
</div>
<div>
<label>Apellido:
<input name="apellido" type="text" required />
</label>
</div>
<div>
<label>Correo:
<input name="correo" type="email" />
</label>
</div>
<div>
<label>Teléfono:
<input name="telefono" type="text" />
</label>
</div>
<div>
<label>Fecha de nacimiento:
<input name="fec_nacimiento" type="date" />
</label>
</div>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead>
<tr>
<th>ID</th><th>Documento</th><th>Nombre</th><th>Apellido</th>
<th>Correo</th><th>Teléfono</th><th>Nacimiento</th><th>Activo</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/usuarios';
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(u => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.id_usuario}</td>
<td>${u.documento ?? ''}</td>
<td>${u.nombre}</td>
<td>${u.apellido}</td>
<td>${u.correo ?? ''}</td>
<td>${u.telefono ?? ''}</td>
<td>${u.fec_nacimiento ? u.fec_nacimiento.substring(0,10) : ''}</td>
<td>${u.activo ? 'Sí' : 'No'}</td>
`;
tbody.appendChild(tr);
});
}
document.getElementById('form-usuario').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
if (payload.fec_nacimiento === '') delete payload.fec_nacimiento;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
listar();
</script>
</body>
</html>

View File

@ -0,0 +1,18 @@
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">Estado de Comandas</h1>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="soloAbiertas">
<label class="form-check-label" for="soloAbiertas">Solo abiertas</label>
</div>
</div>
<!-- tu tabla/listado acá -->
<table class="table table-sm table-bordered">
<thead><tr><th>ID</th><th>Mesa</th><th>Estado</th></tr></thead>
<tbody>
<tr>
<td>1</td><td>5</td>
<td><span class="badge badge-outline badge-estado-abierta">Abierta</span></td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="es">
<head>
<% include ../partials/_head %>
</head>
<body data-page="<%= pageId %>">
<% include ../partials/_navbar %>
<main class="container">
<%- body %>
</main>
<% include ../partials/_sidebar %>
<% include ../partials/_footer %>
</body>
</html>

View File

@ -0,0 +1,42 @@
<!-- /partials/_footer.html -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
/**
* (Solo si usas HTML estático)
* Carga “partials” desde elementos con [data-include="/partials/..."].
* Si usas EJS/templating, podés quitar esto.
*/
async function scLoadPartials(){
const includes = document.querySelectorAll("[data-include]");
for (const el of includes) {
const url = el.getAttribute("data-include");
try {
const res = await fetch(url, {cache:"no-store"});
el.innerHTML = await res.text();
} catch (e) {
el.innerHTML = `<div class="text-danger small">No se pudo cargar ${url}</div>`;
}
}
}
// Export util por si querés llamarlo manualmente
window.scLoadPartials = scLoadPartials;
// Eventos genéricos que el sidebar dispara (ajustá a tu lógica real)
window.addEventListener("sc:toggle-abiertas", () => {
// Ej.: togglear checkbox/estado en páginas que lo usen
const chk = document.getElementById("soloAbiertas");
if (chk) { chk.checked = !chk.checked; chk.dispatchEvent(new Event("change")); }
});
window.addEventListener("sc:export-csv", () => {
// Implementá tu export acá
if (window.scExportCsv) return window.scExportCsv();
alert("Exportar CSV: implementame 😄");
});
window.addEventListener("sc:refresh-list", () => {
if (window.scRefreshList) return window.scRefreshList();
location.reload();
});
</script>

View File

@ -0,0 +1,22 @@
<!-- /partials/_head.html -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= typeof pageTitle !== "undefined" ? pageTitle : "SuiteCoffee" %></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root { --navbar-h: 56px; }
body { padding-top: var(--navbar-h); background: #f7f8fb; }
.brand-mini { font-weight: 700; letter-spacing: .2px; }
/* Layout contenedor principal */
main { padding-block: 1rem 2rem; }
/* Tabla compacta */
.table-sm th, .table-sm td { padding: .5rem .6rem; }
/* Chips/etiquetas de estado */
.badge-outline { border: 1px solid #dee2e6; background: #fff; color: #495057; }
.badge-estado-abierta { border-color:#198754; color:#198754; }
.badge-estado-cerrada { border-color:#6c757d; color:#6c757d; }
.badge-estado-anulada { border-color:#dc3545; color:#dc3545; }
.badge-estado-pagada { border-color:#146c43; color:#146c43; }
</style>

View File

@ -0,0 +1,29 @@
<!-- /partials/_navbar.html -->
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom fixed-top">
<div class="container-fluid">
<a class="navbar-brand brand-mini" href="/">SuiteCoffee</a>
<!-- Links principales (colapsables en mobile) -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#scNav" aria-controls="scNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <!-- hamburguesa principal -->
</button>
<div class="collapse navbar-collapse" id="scNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0 small">
<li class="nav-item"><a class="nav-link" href="/comandas">Comandas</a></li>
<li class="nav-item"><a class="nav-link" href="/estadoComandas">Estado</a></li>
<li class="nav-item"><a class="nav-link" href="/productos">Productos</a></li>
<li class="nav-item"><a class="nav-link" href="/mesas">Mesas</a></li>
<li class="nav-item"><a class="nav-link" href="/reportes">Reportes</a></li>
<!-- agrega las que necesites -->
</ul>
<!-- Botón “hamburguesa” para abrir el menú contextual (sidebar derecha) -->
<button class="btn btn-outline-secondary btn-sm d-flex align-items-center" type="button"
data-bs-toggle="offcanvas" data-bs-target="#scSidebar" aria-controls="scSidebar">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" class="me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
Opciones
</button>
</div>
</div>
</nav>

View File

@ -0,0 +1,62 @@
<!-- /partials/_sidebar.html -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="scSidebar" aria-labelledby="scSidebarLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="scSidebarLabel">Opciones</h5>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Cerrar"></button>
</div>
<div class="offcanvas-body">
<!-- Contenido se inyecta según la página actual -->
<div id="scSidebarContent" class="list-group list-group-flush small"></div>
</div>
</div>
<script>
// Map de opciones por página. Usa body[data-page] o window.scPageId.
const SC_SIDEBAR_ITEMS = {
// === ejemplos ===
"estadoComandas": [
{ text: " Nueva comanda", href: "/comandas" },
{ text: "Solo abiertas", href: "#", attr: { "data-action": "toggle-abiertas" } },
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
{ text: "Actualizar listado", href: "#", attr: { "data-action": "refresh-list" } },
],
"comandas": [
{ text: "Volver a Estado", href: "/estadoComandas" },
{ text: "Cargar productos", href: "/productos" },
{ text: "Mesas", href: "/mesas" },
],
"productos": [
{ text: "Nuevo producto", href: "/productos/nuevo" },
{ text: "Importar catálogo", href: "/productos/importar" },
{ text: "Reportes", href: "/reportes" },
]
};
(function initSidebar(){
const page = (document.body.dataset.page || window.scPageId || "").trim();
const items = SC_SIDEBAR_ITEMS[page] || [
{ text: "Inicio", href: "/" }
];
const box = document.getElementById("scSidebarContent");
box.innerHTML = "";
for (const it of items) {
const a = document.createElement("a");
a.className = "list-group-item list-group-item-action";
a.textContent = it.text;
a.href = it.href || "#";
if (it.attr) for (const [k,v] of Object.entries(it.attr)) a.setAttribute(k,v);
box.appendChild(a);
}
// Acciones ejemplo (opcionales). Adaptá a tus funciones reales.
box.addEventListener("click", (ev) => {
const a = ev.target.closest("a[data-action]");
if (!a) return;
ev.preventDefault();
const action = a.getAttribute("data-action");
if (action === "toggle-abiertas") window.dispatchEvent(new CustomEvent("sc:toggle-abiertas"));
if (action === "export-csv") window.dispatchEvent(new CustomEvent("sc:export-csv"));
if (action === "refresh-list") window.dispatchEvent(new CustomEvent("sc:refresh-list"));
});
})();
</script>