Conexión satisfactoria con la base de datos creada para el workarround, las tablas, columnas y filas se muestran en el bashboard
This commit is contained in:
parent
922da441eb
commit
09610df995
@ -12,349 +12,314 @@ 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
|
||||
|
||||
// ----------------------------------------------------------
|
||||
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);
|
||||
|
||||
// Helper de consulta con acquire/release explícito (del código original, referencial)
|
||||
// async function q(text, params) {
|
||||
// const client = await pool.connect();
|
||||
// try {
|
||||
// return await client.query(text, params);
|
||||
// } finally {
|
||||
// client.release();
|
||||
// }
|
||||
// }
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 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';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Rutas de UI
|
||||
// ----------------------------------------------------------
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'pages', 'dashboard.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 });
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// === 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}`);
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Inicio del servidor
|
||||
// ----------------------------------------------------------
|
||||
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' });
|
||||
});
|
||||
|
||||
293
services/manso/src/pages/dashboard.html
Normal file
293
services/manso/src/pages/dashboard.html
Normal 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>
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user