primer commit del README

This commit is contained in:
Arcangel 2026-08-19 17:28:16 -04:00
commit c76bf6d38a
44 changed files with 10479 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
out/

106
README.md Normal file
View File

@ -0,0 +1,106 @@
# 🎖️ Sistema de Orden de Mérito - Ejército Bolivariano
<div align="center">
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron">
<img src="https://img.shields.io/badge/Node.js-43853D?style=for-the-badge&logo=node.js&logoColor=white" alt="Node.js">
<img src="https://img.shields.io/badge/SQLite-07405E?style=for-the-badge&logo=sqlite&logoColor=white" alt="SQLite">
</div>
## 📌 Descripción del Proyecto
El **Sistema de Orden de Mérito** es una aplicación de escritorio moderna y segura desarrollada bajo la tecnología Electron. Su principal función es gestionar, evaluar y procesar la clasificación del personal militar del Ejército Bolivariano.
El sistema permite importar listados de profesionales en formato Excel (`.xlsx`, `.xls`), procesar los datos de manera estructurada, generar rankings en tiempo real, visualizar el **Top 10** de manera gráfica, listar al personal no recomendado y exportar los resultados automatizados en reportes PDF adaptados a los estándares de la institución.
## 📖 Biografía / Propósito
Este proyecto fue concebido y desarrollado con la misión de automatizar y dar total transparencia al proceso de evaluación y clasificación del personal militar activo. Al sistematizar la **Orden de Mérito**, la institución asegura que se reconozca el esfuerzo, la constancia y el desempeño del profesional, agilizando procesos administrativos que tradicionalmente requerían la revisión manual de amplios archivos. Este sistema representa un salto cualitativo hacia la modernización tecnológica y la eficiencia organizacional dentro del Ejército Bolivariano.
## 🎨 Arquitectura del Sistema (Gráfico)
```mermaid
graph TD
A[Usuario / Administrador] -->|Interactúa| B(Interfaz de Usuario HTML/CSS/JS)
B -->|Context Bridge / IPC| C{Main Process - main.js}
C -->|Importar| D[excelProcessor.js]
D -->|Archivos .xlsx / .xls| E[(Sistema de Archivos)]
C -->|Consultas y Guardado| F[database.js]
F -->|better-sqlite3| G[(Base de Datos SQLite)]
C -->|Generar Reporte| H[pdfGenerator.js]
H -->|PDFKit| I[Archivo .pdf Final]
B -->|Gráficas| J[Chart.js]
```
## 🛠️ Tecnologías (Stack)
El proyecto utiliza un conjunto sólido de herramientas modernas de JavaScript:
- **Core de la Aplicación:** [Electron](https://www.electronjs.org/) + [Node.js](https://nodejs.org/)
- **Base de Datos:** SQLite usando `better-sqlite3` para máxima rápidez y confiabilidad.
- **Procesamiento de Archivos:** `exceljs` para la lectura y parseo de plantillas de Excel.
- **Generación de Reportes:** `pdfkit` para la creación nativa y diseño de PDFs.
- **Visualización de Datos:** `chart.js` para los gráficos estadísticos e indicadores visuales.
- **Empaquetado:** `electron-forge` y `electron-builder` para la compilación de ejecutables portables.
## 📋 Requerimientos Previos
Para ejecutar o contribuir a este entorno de desarrollo, asegúrese de cumplir con los siguientes requisitos en su sistema local:
- **Node.js**: Versión 16.x o superior.
- **npm**: Administrador de paquetes (incluido con Node.js).
- **Sistema Operativo**: Windows (x64 / ia32). Las arquitecturas se pueden expandir según sea necesario.
- **Git**: Opcional, pero recomendado para el control de versiones.
## 🚀 Instalación y Uso Local
1. **Clonar o descargar** este repositorio en su entorno local.
2. Abrir una terminal en el directorio del proyecto y ejecutar el siguiente comando para instalar las dependencias:
```bash
npm install
```
3. (Opcional) Si en Windows `better-sqlite3` requiere re-compilar los binarios nativos después de la instalación inicial:
```bash
npm run postinstall
```
4. **Ejecutar en modo de desarrollo**:
```bash
npm start
```
## 📦 Compilación (Build)
Para empaquetar el proyecto en un archivo ejecutable (Windows Installer / Portable), use los comandos preconfigurados:
- Empaquetar usando Electron Builder:
```bash
npm run build
```
- Empaquetar usando Electron Forge:
```bash
npm run make
```
Los archivos resultantes se encontrarán dentro de la carpeta `dist/` o `out/`.
## 📁 Estructura del Proyecto
```text
📦 ProyectosElectron2
┣ 📂 assets # Íconos, imágenes e identidad visual corporativa
┣ 📂 data # Almacenamiento local de la DB SQLite y estado
┣ 📂 src # Archivos frontend: UI (Vistas), CSS, JS nativo y Audios
┣ 📜 main.js # Proceso principal de Electron y manejo IPC
┣ 📜 preload.js # Puente seguro (Context Isolation)
┣ 📜 database.js # Lógica de operaciones CRUD en SQLite
┣ 📜 excelProcessor.js # Algoritmos para leer e interpretar las plantillas Excel
┣ 📜 pdfGenerator.js # Lógica de renderizado y maquetado de los reportes PDF
┣ 📜 forge.config.js # Configuración para la empaquetación mediante Forge
┣ 📜 package.json # Dependencias y scripts
┗ 📜 README.md # Documentación principal
```
---
**Copyright © 2024 Ejército Bolivariano.**
*Sistema diseñado exclusivamente para la clasificación por orden de mérito.*

BIN
assets/SamiPro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

BIN
assets/imagen1.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
assets/imagen1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

BIN
assets/imagen2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
assets/sami_icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
data/data1.xlsx Normal file

Binary file not shown.

227
database.js Normal file
View File

@ -0,0 +1,227 @@
const Database = require('better-sqlite3');
const path = require('path');
const { app } = require('electron');
const dbPath = path.join(app.getPath("documents"), "orden-merito.db");
let db;
function initializeDatabase() {
try {
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS profesionales (
id INTEGER PRIMARY KEY AUTOINCREMENT,
grado TEXT NOT NULL,
nombreApellido TEXT NOT NULL,
cedula TEXT NOT NULL UNIQUE,
condicion TEXT NOT NULL DEFAULT 'No posee novedades actualmente',
conducta INTEGER NOT NULL CHECK (conducta BETWEEN 0 AND 100),
companeros TEXT NOT NULL,
om_a TEXT NOT NULL,
niea REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_condicion ON profesionales (condicion);
CREATE INDEX IF NOT EXISTS idx_niea ON profesionales (niea);
`);
} catch (err) {
console.error("Error inicializando base de datos:", err);
app.quit();
}
}
initializeDatabase();
function insertProfesionales(profesionales) {
const deleteStmt = db.prepare('DELETE FROM profesionales');
const insertStmt = db.prepare(`
INSERT INTO profesionales (
grado, nombreApellido, cedula, condicion,
conducta, companeros, om_a, niea
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const transaction = db.transaction((profesionales) => {
deleteStmt.run();
for (const prof of profesionales) {
// Normalizar el campo companeros
let companeros = prof.companeros?.toString().trim() || '0';
if (companeros === '0' || companeros === '') {
companeros = '0/0'; // Convertir 0 o vacío a 0/0
} else if (!companeros.includes('/')) {
companeros = `${companeros}/90`; // Si solo tiene numerador, asumir denominador 90
}
// Normalizar el campo om_a de la misma manera
let om_a = prof.om_a?.toString().trim() || '0';
if (om_a === '0' || om_a === '') {
om_a = '0/0';
} else if (!om_a.includes('/')) {
om_a = `${om_a}/90`;
}
insertStmt.run(
prof.grado?.toString().trim() || '',
prof.nombreApellido?.toString().trim() || '',
prof.cedula?.toString().trim() || '',
String(prof.condicion || '').trim().toLowerCase() || 'No posee novedades actualmente',
parseFloat(prof.conducta) || 0,
companeros,
om_a,
parseFloat(prof.niea) || 0
);
}
});
try {
transaction(profesionales);
} catch (err) {
console.error("Error insertando profesionales:", err);
throw err;
}
}
function calcularResultado(prof) {
try {
const puntajeConducta = (parseFloat(prof.conducta) || 0) * 0.15;
// Manejo especial para campo "compañeros"
let puntajeCompaneros = 0;
if (prof.companeros && prof.companeros !== '0') {
if (prof.companeros.includes('/')) {
const [posCompaneros, totalCompaneros] = prof.companeros.split('/').map(Number);
puntajeCompaneros = totalCompaneros > 0 ? (1 - (posCompaneros / totalCompaneros)) * 5 : 0;
} else {
// Si no tiene formato de fracción, asumir que es el numerador con denominador 90
const posCompaneros = Number(prof.companeros);
puntajeCompaneros = (1 - (posCompaneros / 90)) * 5;
}
}
// Manejo similar para campo "om_a"
let puntajeOM = 0;
if (prof.om_a && prof.om_a !== '0') {
if (prof.om_a.includes('/')) {
const [posOM, totalOM] = prof.om_a.split('/').map(Number);
puntajeOM = totalOM > 0 ? (1 - (posOM / totalOM)) * 30 : 0;
} else {
const posOM = Number(prof.om_a);
puntajeOM = (1 - (posOM / 90)) * 30;
}
}
const puntajeNIEA = (parseFloat(prof.niea) || 0) * 0.5;
return puntajeConducta + puntajeCompaneros + puntajeOM + puntajeNIEA;
} catch (err) {
console.error("Error calculando resultado:", err);
return 0;
}
}
function getRanking() {
try {
const stmt = db.prepare(`
SELECT *,
CASE
WHEN LOWER(condicion) = 'no posee novedades actualmente' THEN 0
WHEN condicion LIKE '%sancion simple%grados anteriores%' THEN 1
WHEN condicion LIKE '%privado%libertad%' THEN 3
ELSE 2
END AS priority_level,
(CAST(conducta AS REAL) * 0.15 +
(
(1 - (
CAST(SUBSTR(companeros, 1, INSTR(companeros, '/') - 1) AS REAL) /
CAST(SUBSTR(companeros, INSTR(companeros, '/') + 1) AS INTEGER)
)) * 5
) +
(
(1 - (
CAST(SUBSTR(om_a, 1, INSTR(om_a, '/') - 1) AS REAL) /
CAST(SUBSTR(om_a, INSTR(om_a, '/') + 1) AS INTEGER)
)) * 30
) +
(niea * 0.5)
) AS resultado -- Paréntesis de cierre correcto
FROM profesionales
ORDER BY
priority_level ASC,
CASE WHEN condicion LIKE '%privado%libertad%' THEN 1 ELSE 0 END,
resultado DESC
`);
return stmt.all();
} catch (err) {
console.error("Error obteniendo ranking:", err);
return [];
}
}
function getNoRecomendados() {
try {
return getRanking().filter(p => {
const cond = p.condicion.toLowerCase();
// Lógica para sanciones simples (contar ocurrencias)
const sancionesSimples = (cond.match(/(\d+)\s*d[ií]as de sanci[oó]n simple/g) || []).length;
// Lógica mejorada para sanciones severas (sumar días)
const sancionesSeverasMatches = cond.match(/(\d+)\s*d[ií]as de sanci[oó]n severa/g) || [];
let totalDiasSeveros = 0;
for (const match of sancionesSeverasMatches) {
const diasMatch = match.match(/\d+/);
if (diasMatch) totalDiasSeveros += parseInt(diasMatch[0], 10);
}
const investigacionAbierta = /(investigaci[oó]n|juicio).*(abiert|pendiente|vigente)/.test(cond);
const privadoLibertad = /privado de libertad/i.test(cond);
return sancionesSimples >= 15 ||
totalDiasSeveros >= 8 || // Ahora verifica días totales
investigacionAbierta ||
privadoLibertad;
});
} catch (err) {
console.error("Error obteniendo no recomendados:", err);
return [];
}
}
function getTop10() {
try {
const ranking = getRanking()
.filter(p => p.condicion.toLowerCase() === 'no posee novedades actualmente')
.slice(0, 10);
return ranking;
} catch (err) {
console.error("Error obteniendo top 10:", err);
return [];
}
}
app.on("window-all-closed", () => {
if (db) db.close();
});
module.exports = {
insertProfesionales,
getRanking,
getNoRecomendados,
getTop10,
calcularResultado
};

77
excelProcessor.js Normal file
View File

@ -0,0 +1,77 @@
const ExcelJS = require("exceljs");
const { insertProfesionales } = require("./database");
async function processExcelFile(filePath) {
const workbook = new ExcelJS.Workbook();
try {
await workbook.xlsx.readFile(filePath);
const worksheet = workbook.worksheets[0];
const profesionales = [];
const parseCondicion = (condicion) => {
const cond = condicion.toLowerCase();
return {
sancionesSimples: (cond.match(/(\d+)\s*d[ií]as de sanci[oó]n simple/g) || []).length,
sancionesSeveras: (cond.match(/(\d+)\s*d[ií]as de sanci[oó]n severa/g) || []).length,
investigacionAbierta: /investigaci[oó]n (administrativa|penal) abierta/.test(cond),
juicioAbierto: /juicio abierto/.test(cond)
};
};
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
if (rowNumber > 1) {
try {
const rawData = {
grado: row.getCell(2).value?.toString().trim() || '',
nombreApellido: row.getCell(3).value?.toString().trim() || '',
cedula: row.getCell(4).value?.toString().trim() || '',
condicion: (row.getCell(5).value?.toString().trim().toLowerCase() || "no posee novedades actualmente"),
conducta: Math.min(Math.max(parseInt(row.getCell(6).value || 0, 10), 0), 100),
companeros: (row.getCell(7).value?.toString().trim().replace(/[^0-9\/]/g, '') || "0/0"),
om_a: (row.getCell(8).value?.toString().trim().replace(/[^0-9\/]/g, '') || "0/0"),
niea: (() => {
const value = row.getCell(9).value?.toString().replace(/,/g, '.');
const parsed = parseFloat(value);
if (isNaN(parsed)) throw new Error(`Valor NIEA inválido: '${value}'`);
return parsed;
})()
};
const validarFormatoPT = (valor, campo) => {
if (!valor.includes('/')) throw new Error(`${campo}: Formato debe ser P/T`);
const partes = valor.split('/');
if (partes.length !== 2) throw new Error(`${campo}: Formato inválido`);
const [p, t] = partes;
const numP = parseInt(p, 10);
const numT = parseInt(t, 10);
if (isNaN(numP)) throw new Error(`${campo}: Posición no numérica ('${p}')`);
if (isNaN(numT)) throw new Error(`${campo}: Total no numérico ('${t}')`);
if (numT <= 0) throw new Error(`${campo}: Total debe ser > 0`);
if (numP <= 0) throw new Error(`${campo}: Posición debe ser > 0`);
if (numP >= numT) throw new Error(`${campo}: Posición (${p}) debe ser < Total (${t})`);
};
validarFormatoPT(rawData.companeros, "Compañeros");
validarFormatoPT(rawData.om_a, "OM-A");
profesionales.push(rawData);
} catch (error) {
throw new Error(`Fila ${rowNumber}: ${error.message}`);
}
}
});
await insertProfesionales(profesionales);
return profesionales;
} catch (error) {
console.error("Error procesando Excel:", error);
throw new Error(`Error en archivo: ${error.message}`);
}
}
module.exports = { processExcelFile };

47
forge.config.js Normal file
View File

@ -0,0 +1,47 @@
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
module.exports = {
packagerConfig: {
asar: true,
extraResource: [
"preload.js" // Asegurar que se incluya en el build
]
},
rebuildConfig: {},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {},
},
{
name: '@electron-forge/maker-zip',
platforms: ['darwin'],
},
{
name: '@electron-forge/maker-deb',
config: {},
},
{
name: '@electron-forge/maker-rpm',
config: {},
},
],
plugins: [
{
name: '@electron-forge/plugin-auto-unpack-natives',
config: {},
},
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
};

261
main.js Normal file
View File

@ -0,0 +1,261 @@
const { app, BrowserWindow, ipcMain, dialog, Menu } = require("electron");
const path = require("path");
const fs = require("fs");
// Módulos locales
const { generatePDF } = require("./pdfGenerator");
const { processExcelFile } = require("./excelProcessor");
const {
getTop10,
getRanking,
getNoRecomendados,
insertProfesionales,
} = require("./database");
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1000,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
enableRemoteModule: false,
devTools: true,
sandbox: true,
preload: path.join(__dirname, "preload.js"),
webSecurity: true,
allowRunningInsecureContent: false,
},
title: "Sistema de Orden de Mérito",
icon: path.join(
__dirname,
"assets",
process.platform === "win32" ? "sami_icon.ico" : "imagen1.png"
),
});
mainWindow.loadFile(path.join(__dirname, "src", "views", "main.html"));
mainWindow.maximize();
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools();
}
setupMenu(); // Menú para desarrollo
}
function setupMenu() {
const isMac = process.platform === "darwin";
const template = [
...(isMac
? [
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideothers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
]
: []),
{
label: "Archivo",
submenu: [{ role: "quit" }],
},
{
label: "Ver",
submenu: [
{ role: "reload", label: "Recargar" },
{ role: "forceReload", label: "Recarga Forzada" },
{ role: "toggleDevTools", label: "Herramientas de Desarrollador" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Ventana",
submenu: [
{ role: "minimize" },
{ role: "zoom" },
...(isMac
? [
{ type: "separator" },
{ role: "front" },
{ type: "separator" },
{ role: "window" },
]
: [{ role: "close" }]),
],
},
{
label: "Ayuda",
submenu: [
{
label: "Más información",
click: async () => {
await require("electron").shell.openExternal("https://electronjs.org");
},
},
],
},
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
function setupIPCHandlers() {
const handlers = [
"dialog:openFile",
"excel:process",
"ranking:generate",
"pdf:generate",
"getDownloadsPath",
"ranking:get",
"ranking:getTop10",
"nonrecommended:get",
"fs:exists",
"get:assetsPath",
];
handlers.forEach((handler) => ipcMain.removeHandler(handler));
ipcMain.handle("dialog:openFile", async () => {
const result = await dialog.showOpenDialog({
title: "Seleccionar archivo de Excel",
properties: ["openFile"],
filters: [
{ name: "Archivos de Excel", extensions: ["xlsx", "xls"] },
{ name: "Todos los archivos", extensions: ["*"] },
],
});
return result.canceled ? null : result.filePaths[0];
});
ipcMain.handle("excel:process", async (_, filePath) => {
try {
const profesionales = await processExcelFile(filePath);
await insertProfesionales(profesionales);
return profesionales;
} catch (error) {
console.error(error);
throw new Error(`Error procesando Excel: ${error.message}`);
}
});
ipcMain.handle("ranking:get", async () => {
try {
return await getRanking();
} catch (error) {
throw new Error(`Error obteniendo ranking: ${error.message}`);
}
});
ipcMain.handle("ranking:getTop10", async () => {
try {
return await getTop10();
} catch (error) {
throw new Error(`Error obteniendo Top 10: ${error.message}`);
}
});
ipcMain.handle('generate-pdf', async (event, data) => {
try {
const resultado = await generatePDF(data);
return resultado;
} catch (error) {
console.error("Error al generar PDF:", error);
throw error;
}
});
ipcMain.handle("getDownloadsPath", () => app.getPath("downloads"));
ipcMain.handle("nonrecommended:get", async () => {
try {
return await getNoRecomendados();
} catch (error) {
throw new Error(`Error obteniendo no recomendados: ${error.message}`);
}
});
ipcMain.handle("fs:exists", async (_, filePath) => {
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
});
ipcMain.handle("get:assetsPath", () => {
return path.join(__dirname, "assets");
});
ipcMain.handle("getAudioPath", (_, audioName) => {
const audioDir = path.join(__dirname, "src", "audios");
const audioPath = path.join(audioDir, `${audioName}.mp3`);
if (fs.existsSync(audioPath)) {
const finalPath = `file://${audioPath.replace(/\\/g, '/')}`;
console.log("✅ Devolviendo ruta de audio:", finalPath);
return finalPath;
} else {
console.warn("❌ Audio no encontrado:", audioPath);
return null;
}
});
}
app.whenReady().then(() => {
app.commandLine.appendSwitch('--enable-speech-dispatcher');
app.commandLine.appendSwitch('--enable-speech-synthesis');
app.commandLine.appendSwitch('--enable-features=WinRTComVoiceActivation'); // Para Windows
createWindow();
setupIPCHandlers();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
setupIPCHandlers();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
process.on("unhandledRejection", (error) => {
console.error("Unhandled Rejection:", error);
dialog.showErrorBox("Error no manejado", error.message || "Error desconocido");
});
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);
dialog.showErrorBox("Excepción no capturada", error.message || "Error crítico");
app.quit();
});

7788
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

77
package.json Normal file
View File

@ -0,0 +1,77 @@
{
"name": "proyectoselectron",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron-forge start",
"build": "electron-builder --win --x64 --ia32",
"package": "electron-forge package",
"postinstall": "electron-builder install-app-deps",
"make": "electron-forge make"
},
"build": {
"appId": "com.ejercito.merito",
"productName": "Sistema de Orden de Mérito",
"copyright": "Copyright © 2024 Ejército Bolivariano",
"asarUnpack": [
"**/better-sqlite3.node"
],
"directories": {
"output": "dist",
"buildResources": "assets"
},
"files": [
"**/*",
"!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
"!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}",
"node_modules/chart.js/dist/chart.umd.js",
"!**/*.map"
],
"win": {
"target": [
{
"target": "portable",
"arch": [
"x64",
"ia32"
]
}
],
"icon": "assets/sami_icon.ico",
"artifactName": "SistemaMerito-${version}-${arch}.exe"
},
"extraResources": [
{
"from": "src/audios",
"to": "audios",
"filter": [
"**/*.mp3"
]
},
{
"from": "assets",
"to": "assets",
"filter": [
"**/*"
]
}
]
},
"devDependencies": {
"@electron-forge/cli": "^7.8.0",
"@electron-forge/maker-squirrel": "^7.8.0",
"@electron-forge/maker-zip": "^7.8.0",
"@electron-forge/plugin-auto-unpack-natives": "^7.8.0",
"@electron-forge/plugin-fuses": "^7.8.0",
"electron": "^35.0.3",
"electron-builder": "^26.0.12"
},
"dependencies": {
"better-sqlite3": "^11.9.1",
"chart.js": "^4.4.2",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^2.30.0",
"exceljs": "^4.4.0",
"pdfkit": "^0.16.0"
}
}

165
pdfGenerator.js Normal file
View File

@ -0,0 +1,165 @@
const { dialog } = require('electron');
const PDFDocument = require('pdfkit');
const fs = require('fs');
const path = require('path');
async function generatePDF(data, tipo = 'orden') {
console.log(`📄 [PDF] Generando PDF tipo "${tipo}" para ${data.length} registros`);
if (!Array.isArray(data)) throw new Error('Datos inválidos');
const { filePath } = await dialog.showSaveDialog({
title: tipo === 'orden' ? "Guardar Orden de Mérito" : "Guardar Lista de No Recomendados",
filters: [{ name: "PDF Files", extensions: ["pdf"] }]
});
if (!filePath) throw new Error('Operación cancelada');
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 30, size: 'letter', bufferPages: true });
const stream = fs.createWriteStream(filePath);
stream.on('error', err => {
console.error("❌ Error en el stream:", err.message);
reject(new Error("Fallo en la escritura del archivo PDF"));
});
stream.on('finish', () => {
console.log("✅ PDF finalizado:", filePath);
resolve({ success: true, path: filePath });
});
doc.pipe(stream);
const generateContent = async () => {
try {
const logoLeft = path.join(__dirname, 'assets', 'imagen1.png');
const logoRight = path.join(__dirname, 'assets', 'imagen2.png');
const logoWidth = 60; // Tamaño unificado para ambos logos
if (fs.existsSync(logoLeft)) {
doc.image(logoLeft, 30, 30, { width: logoWidth });
}
if (fs.existsSync(logoRight)) {
doc.image(logoRight, doc.page.width - 30 - logoWidth, 30, { width: logoWidth });
}
// Espacio para los logos (que están a altura Y = 30), dejamos un poco más abajo el texto
const headerStartY = 100;
doc.font('Helvetica-Bold')
.fontSize(10)
.text('REPÚBLICA BOLIVARIANA DE VENEZUELA', { align: 'center', baseline: 'top', continued: false })
.moveDown(0.5)
.text('MINISTERIO DEL PODER POPULAR PARA LA DEFENSA', { align: 'center' })
.moveDown(0.5)
.text('EJÉRCITO BOLIVARIANO', { align: 'center' })
.moveDown(0.5)
.text('DIRECCIÓN DE TECNOLOGIA DE LA INFORMACIÓN Y LAS COMUNICACIONES', { align: 'center' });
doc.moveDown(2); // espacio después del encabezado
// Título según tipo
const title = tipo === 'orden' ? 'ORDEN DE MÉRITO OFICIAL' : 'LISTADO DE NO RECOMENDADOS';
doc.fontSize(12).text(title, { align: 'center' }).moveDown(2);
// Filtrado de data
const isOrden = tipo === 'orden';
const filteredData = isOrden
? [...data].sort((a, b) => (b.resultado ?? b.niea ?? 0) - (a.resultado ?? a.niea ?? 0))
: data.filter(item => !item.condicion?.toLowerCase().includes('recomendado'));
// Columnas
const headers = isOrden
? ['ID', 'GRADO', 'NOMBRE Y APELLIDO', 'CÉDULA', 'NIEA', 'RESULTADO']
: ['ID', 'GRADO', 'NOMBRE Y APELLIDO', 'CÉDULA', 'MOTIVO'];
const widths = isOrden
? [30, 70, 200, 90, 60, 60]
: [30, 70, 220, 90, 100];
let y = doc.y + 10;
doc.font('Helvetica-Bold').fontSize(9);
drawRow(y, headers, widths);
y += 22;
filteredData.forEach((item, index) => {
if (y > doc.page.height - 100) {
doc.addPage();
y = 40;
}
const row = isOrden
? [
index + 1,
item.grado || 'N/D',
item.nombreApellido || 'N/D',
item.cedula?.toString() || 'N/D',
(item.niea ?? item.resultado ?? 0).toFixed(3),
(item.resultado ?? item.niea ?? 0).toFixed(3)
]
: [
index + 1,
item.grado || 'N/D',
item.nombreApellido || 'N/D',
item.cedula?.toString() || 'N/D',
item.condicion || 'Sin especificar'
];
doc.font('Helvetica').fontSize(8);
drawRow(y, row, widths);
y += 22;
});
// Firmas
const signatureY = y + 30;
doc.fontSize(9)
.text('_________________________', 50, signatureY, { width: 200, align: 'center' })
.text('CNEL MIGUEL J. RODRÍGUEZ', 50, signatureY + 15, { width: 200, align: 'center' })
.text('_________________________', doc.page.width - 250, signatureY, { width: 200, align: 'center' })
.text('MAYO JOSÉ M. SÁNCHEZ', doc.page.width - 250, signatureY + 15, { width: 200, align: 'center' });
} catch (err) {
throw new Error("Contenido falló: " + err.message);
}
};
function drawRow(y, rowData, widths) {
let x = 30;
rowData.forEach((text, i) => {
doc.text(text.toString(), x + 5, y + 5, {
width: widths[i] - 10,
height: 20,
align: 'left',
ellipsis: true
});
x += widths[i];
});
drawBorders(y, widths);
}
function drawBorders(y, widths) {
let x = 30;
widths.forEach((w, i) => {
if (i < widths.length - 1) {
doc.moveTo(x + w, y).lineTo(x + w, y + 22).stroke();
}
x += w;
});
doc.moveTo(30, y).lineTo(doc.page.width - 30, y).stroke();
}
generateContent()
.then(() => doc.end())
.catch(err => {
console.error("❌ Error al generar contenido:", err.message);
doc.end();
reject(err);
});
});
}
module.exports = { generatePDF };

18
preload.js Normal file
View File

@ -0,0 +1,18 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
getRanking: () => ipcRenderer.invoke("ranking:get"),
getTop10: () => ipcRenderer.invoke("ranking:getTop10"),
openFile: () => ipcRenderer.invoke("dialog:openFile"),
processExcel: (filePath) => ipcRenderer.invoke("excel:process", filePath),
getDownloadsPath: () => ipcRenderer.invoke("getDownloadsPath"),
getNonRecommended: () => ipcRenderer.invoke("nonrecommended:get"),
generatePDF: (data) => ipcRenderer.invoke("generate-pdf", data),
checkFileExists: (filePath) => ipcRenderer.invoke("fs:exists", filePath),
getAssetsPath: () => ipcRenderer.invoke("get:assetsPath"),
getAudioPath: (audioKey) => ipcRenderer.invoke("getAudioPath", audioKey)
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/audios/excel_datos.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
src/audios/inicio_ia.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/fonts/Helvetica.ttf Normal file

Binary file not shown.

92
src/js/boot.js Normal file
View File

@ -0,0 +1,92 @@
async function iniciarBootSequence() {
const bootSteps = [
{
text: "🎙️ Iniciando generador de Procesos...",
audio: "boot_inicio_sistema"
},
{
text: "🧠 Activando núcleo cognitivo...",
audio: "boot_conexion_nucleo"
},
{
text: "⚡Cargando protocolos de integridad y seguridad...",
audio: "boot_sincronizacion_parametros"
},
{
text: "💾 Accediendo a módulos confidenciales...",
audio: "boot_preparando_base"
},
{
text: "✅ Autenticación exitosa...",
audio: "boot_acceso_autorizado"
},
];
const bootMessageEl = document.getElementById("bootMessage");
let current = 0;
const playStep = async () => {
const step = bootSteps[current];
if (!step) {
document.getElementById("bootScreen").classList.add("fade-out");
setTimeout(() => {
document.getElementById("bootScreen").remove(); // Elimina boot
endBootScreen(); // 🚀 Llamamos aquí el paso final: overlay + voz + carga
}, 1000); // Duración del fade-out
return;
}
// Mostrar texto con animación
bootMessageEl.classList.remove("active");
await new Promise(res => setTimeout(res, 300));
bootMessageEl.textContent = step.text;
bootMessageEl.classList.add("active");
// Reproducir audio
try {
const path = await window.electronAPI.getAudioPath(step.audio);
const audio = new Audio(path);
audio.volume = 0.8;
await new Promise(resolve => {
audio.onended = resolve;
audio.onerror = resolve;
audio.play().catch(resolve);
});
} catch (e) {
console.warn("No se pudo cargar audio:", step.audio);
}
current++;
setTimeout(playStep, 500);
};
setTimeout(playStep, 1000);
}
function endBootScreen() {
const overlay = document.getElementById("importOverlay");
overlay.style.display = "block";
helpers.showLoading();
helpers.updateLoadingMessage("Buscando archivo Excel...");
const audioKey = "excel_datos"; // o "dame_los_datos"
window.electronAPI.getAudioPath(audioKey).then((ruta) => {
if (!ruta) {
console.warn("❌ Ruta de audio no encontrada:", audioKey);
handleFileImport();
return;
}
const audio = new Audio(ruta);
audio.onended = () => {
handleFileImport();
};
audio.play().catch((err) => {
console.warn("⚠️ Error al reproducir audio:", err);
handleFileImport();
});
});
}

85
src/js/matrixFace.js Normal file
View File

@ -0,0 +1,85 @@
class MatrixFace {
constructor() {
this.canvas = document.getElementById('matrixCanvas');
this.ctx = this.canvas.getContext('2d');
this.particles = [];
this.image = new Image();
this.image.src = 'sami-face.png'; // Tu imagen de rostro estilo matrix
this.cellSize = 12;
this.matrixChars = '01サミ♡ΛΣΠ';
this.init();
}
init() {
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
this.image.onload = () => {
this.processImage();
this.animate();
};
}
resizeCanvas() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
processImage() {
const tempCanvas = document.createElement('canvas');
const tctx = tempCanvas.getContext('2d');
const scale = 0.3;
tempCanvas.width = this.image.width * scale;
tempCanvas.height = this.image.height * scale;
tctx.drawImage(this.image, 0, 0, tempCanvas.width, tempCanvas.height);
const imageData = tctx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
this.particles = [];
for(let y = 0; y < imageData.height; y += this.cellSize) {
for(let x = 0; x < imageData.width; x += this.cellSize) {
const offset = (y * imageData.width + x) * 4;
const alpha = imageData.data[offset + 3];
if(alpha > 128) {
this.particles.push({
x: (x / tempCanvas.width) * this.canvas.width,
y: (y / tempCanvas.height) * this.canvas.height,
char: this.matrixChars[Math.floor(Math.random() * this.matrixChars.length)],
speed: Math.random() * 2 + 1,
alpha: 1
});
}
}
}
}
draw() {
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.fillStyle = '#0f0';
this.ctx.font = '18px Matrix Code NFI';
this.particles.forEach(particle => {
this.ctx.fillStyle = `rgba(0, 255, 50, ${particle.alpha})`;
this.ctx.fillText(particle.char, particle.x, particle.y);
particle.y += particle.speed;
if(particle.y > this.canvas.height) {
particle.y = -20;
particle.alpha = 1;
} else {
particle.alpha = Math.min(particle.alpha - 0.005, 0.8);
}
});
}
animate() {
this.draw();
requestAnimationFrame(() => this.animate());
}
}

666
src/js/renderer.js Normal file
View File

@ -0,0 +1,666 @@
document.addEventListener("DOMContentLoaded", () => {
const elements = {
importBtn: document.getElementById("importBtn"),
generateBtn: document.getElementById("generateBtn"),
nonRecommendedBtn: document.getElementById("nonRecommendedBtn"),
previewTable: document.querySelector("#previewTable tbody"),
loadingOverlay: document.getElementById("loadingOverlay"),
loadingMessage: document.getElementById("loadingMessage"),
progressBar: document.getElementById("progressBar"),
resultsModal: document.getElementById("resultsModal"),
resultsContainer: document.getElementById("resultsContainer"),
closeResults: document.getElementById("closeResults"),
savePdfBtn: document.getElementById("savePdfBtn"),
audioToggle: document.getElementById("audioToggle")
};
const state = {
currentProfesionales: [],
fullRanking: [],
nonRecommended: [],
audioEnabled: true,
currentAudio: null // Cambiamos a control de audio
};
const helpers = {
showLoading: () => elements.loadingOverlay.classList.add("active"),
hideLoading: () => elements.loadingOverlay.classList.remove("active"),
updateLoadingMessage: (message) => {
elements.loadingMessage.classList.remove("active");
setTimeout(() => {
elements.loadingMessage.textContent = message;
elements.loadingMessage.classList.add("active");
}, 100);
},
updateProgress: (percent) => elements.progressBar.style.width = `${percent}%`,
delay: (ms) => new Promise(resolve => setTimeout(resolve, ms)),
speak: async (audioKey) => {
try {
const audioPath = await window.electronAPI.getAudioPath(audioKey);
if (!audioPath) {
console.warn(`Audio no encontrado: ${audioKey}`);
return 0;
}
return new Promise((resolve, reject) => {
const audio = new Audio(audioPath);
audio.onloadedmetadata = () => {
const duration = audio.duration;
state.currentAudio = audio;
audio.onended = () => resolve(duration);
audio.onerror = () => reject(new Error("Error cargando audio"));
audio.play().catch(reject);
};
});
} catch (error) {
console.error("Error de audio:", error);
showNotification(`Error de audio: ${error.message}`, "error");
return 0;
}
}
};
const setupEventListeners = () => {
elements.importBtn.addEventListener("click", handleFileImport);
elements.generateBtn.addEventListener("click", generateRanking);
elements.nonRecommendedBtn?.addEventListener("click", showNonRecommended);
elements.closeResults.addEventListener("click", () => elements.resultsModal.classList.remove("active"));
elements.savePdfBtn.addEventListener("click", exportToPDF);
elements.audioToggle.addEventListener("click", () => {
state.audioEnabled = !state.audioEnabled;
elements.audioToggle.textContent = state.audioEnabled ? "🔊 Audio ON" : "🔇 Audio OFF";
if (!state.audioEnabled && state.currentAudio) {
state.currentAudio.pause();
state.currentAudio.currentTime = 0;
}
});
};
// Función para manejar la importación de archivo
const handleFileImport = async () => {
try {
if (!window.electronAPI?.openFile || !window.electronAPI?.processExcel) {
throw new Error("API de Electron no disponible. Verifica preload.js.");
}
helpers.showLoading();
helpers.updateLoadingMessage("Buscando archivo Excel...");
const filePath = await window.electronAPI.openFile();
if (!filePath) return helpers.hideLoading();
helpers.updateLoadingMessage("Procesando archivo...");
const resultados = await window.electronAPI.processExcel(filePath);
if (!resultados?.length) throw new Error("El archivo no contiene datos válidos");
const isValid = resultados.every(item => item.grado && item.nombreApellido && item.cedula);
if (!isValid) throw new Error("Estructura del archivo incorrecta");
state.currentProfesionales = resultados;
elements.generateBtn.disabled = false;
elements.nonRecommendedBtn.disabled = false;
updateTable(resultados);
updateCharts(resultados);
showNotification(`Datos cargados: ${resultados.length} registros`, "success");
} catch (error) {
console.error("Error al importar archivo:", error);
showNotification(`Error: ${error.message}`, "error");
} finally {
helpers.hideLoading();
document.getElementById("importOverlay").style.display = "none";
// Reproducir voz que dice que todo está listo
try {
const rutaConfirmacion = await window.electronAPI.getAudioPath("procesamiento_completado");
if (rutaConfirmacion) {
const audioFinal = new Audio(rutaConfirmacion);
audioFinal.play().catch((err) => {
console.warn("⚠️ No se pudo reproducir audio final:", err);
});
}
} catch (err) {
console.warn("⚠️ No se pudo obtener audio final:", err);
}
}
};
const updateTable = (profesionales) => {
elements.previewTable.innerHTML = profesionales.map((p, index) => `
<tr>
<td>${index + 1}</td>
<td>${p.grado}</td>
<td>${p.nombreApellido}</td>
<td>${p.cedula}</td>
</tr>
`).join("");
};
const generateRanking = async () => {
try {
helpers.showLoading();
helpers.updateLoadingMessage("Iniciando sistema de IA...");
if (!state.currentProfesionales?.length) throw new Error("¡Primero importa un archivo Excel!");
const [rankingData] = await Promise.all([window.electronAPI.getRanking(), simulateProgress()]);
state.fullRanking = rankingData;
state.nonRecommended = [];
helpers.updateProgress(100);
helpers.updateLoadingMessage("¡Análisis completado con éxito!");
await helpers.delay(800);
showResults(state.fullRanking);
elements.resultsModal.classList.add("active");
} catch (error) {
console.error("Error generando ranking:", error);
const errorMessage = error.error === 'synthesis-failed' ?
'Error de voz: Verifique las voces instaladas' : error.message;
showNotification(errorMessage, "error");
}finally {
helpers.hideLoading();
}
};
const showNonRecommended = async () => {
try {
helpers.showLoading();
helpers.updateLoadingMessage("Buscando personal no recomendado...");
const noRecomendados = await window.electronAPI.getNonRecommended();
if (!noRecomendados?.length) {
showNotification("¡Todos están recomendados!", "info");
return;
}
state.nonRecommended = noRecomendados; // <== GUARDAMOS AQUÍ
showResults(noRecomendados, "🚫 Personal No Recomendado");
elements.resultsModal.classList.add("active");
} catch (error) {
console.error("Error mostrando no recomendados:", error);
showNotification(`Error: ${error.message}`, "error");
} finally {
helpers.hideLoading();
}
};
const exportToPDF = async () => {
console.log("🖨️ Botón de exportar PDF presionado");
try {
const currentTitle = document.querySelector(".ranking-header h2")?.textContent || "";
let tipo = "orden";
let pdfData = [];
if (currentTitle.includes("No Recomendado")) {
tipo = "noRecomendados";
pdfData = state.nonRecommended;
} else {
tipo = "orden";
pdfData = state.fullRanking;
}
// Ajustar NIEA
pdfData = pdfData.map(item => ({
...item,
niea: item.niea || item.niaActual || item.resultado || 0
}));
const result = await window.electronAPI.generatePDF(pdfData, tipo);
const message = result.success ? `PDF creado en: ${result.path}` : `Error: ${result.error}`;
showNotification(message, result.success ? "success" : "error");
} catch (error) {
console.error("Error exportando PDF:", error);
showNotification(`Error crítico: ${error.message}`, "error");
}
};
const showResults = (profesionales, title = "Orden de Mérito Oficial") => {
let sorted = [...profesionales].sort((a, b) => {
const aLibertad = a.condicion.toLowerCase().includes('privado de libertad') ? 1 : 0;
const bLibertad = b.condicion.toLowerCase().includes('privado de libertad') ? 1 : 0;
return aLibertad - bLibertad || b.resultado - a.resultado;
});
const updateResults = (filteredProfesionales) => {
// 1. Capturar estado actual del input ANTES de renderizar
const prevSearchInput = document.getElementById("searchCedula");
const prevValue = prevSearchInput?.value || "";
const prevCursorPos = prevSearchInput?.selectionStart || 0;
// 2. Generar HTML
elements.resultsContainer.innerHTML = `
<div class="ranking-header">
<h2 class="${title === "🚫 Personal No Recomendado" ? 'extra-margin-bottom' : ''}">${title}</h2>
<div class="search-container">
<input type="text"
id="searchCedula"
class="search-input"
placeholder="🔍 Buscar por cédula..."
value="${prevValue}">
</div>
${title === "Orden de Mérito Oficial" ? `
<div class="legend">
<div class="legend-item">
<div class="color-box recommended"></div>
<span>Recomendado</span>
</div>
<div class="legend-item">
<div class="color-box not-recommended"></div>
<span>No recomendado</span>
</div>
</div>` : ""}
</div>
<div class="card-layout ${title === "🚫 Personal No Recomendado" ? 'results-margin-top' : ''}">
${filteredProfesionales.map((p, index) => `
<div class="prof-card ${getStatusClass(p)}">
<div class="position-container">
<div class="position-badge">${index + 1}</div>
</div>
<div class="main-info">
<div class="header-section">
<div class="name-section">
<div class="prof-name">${p.nombreApellido}</div>
<div class="prof-details">
<span>${p.grado}</span>
<span>Cedula: ${p.cedula}</span>
</div>
</div>
${title !== "🚫 Personal No Recomendado" && index < 3 ?
`<div class="medal-container">${["🥇", "🥈", "🥉"][index]}</div>` : ''}
</div>
<div class="stats-grid">
<div class="stat-pair">
<span class="stat-label">Conducta</span>
<span class="stat-value">${p.conducta}/100</span>
</div>
<div class="stat-pair">
<span class="stat-label">Evaluación Compañeros</span>
<span class="stat-value">${p.companeros}</span>
</div>
<div class="stat-pair">
<span class="stat-label">OM/A</span>
<span class="stat-value">${p.om_a}</span>
</div>
<div class="stat-pair">
<span class="stat-label">NIEA</span>
<span class="stat-value">${(p.niea ?? p.resultado ?? 0).toFixed(3)}</span>
</div>
</div>
${p.condicion.toLowerCase() !== 'no posee novedades actualmente' ?
`<div class="condition-container not-recommended">
${p.condicion.toUpperCase()}
</div>` :
`<div class="condition-container recommended">
👍 No posee novedades actualmente
</div>`}
</div>
<div class="result-container">
<div class="result-percentage">${p.resultado?.toFixed(2)}%</div>
</div>
</div>`).join('')} <!-- Cierre correcto de la tarjeta -->
</div>`;
// 3. Recuperar el nuevo input y configurar foco/cursor
const newSearchInput = document.getElementById("searchCedula");
if (newSearchInput) {
newSearchInput.focus();
newSearchInput.setSelectionRange(prevCursorPos, prevCursorPos);
}
// 4. Manejar búsquedas
newSearchInput?.addEventListener("input", (e) => {
const term = e.target.value.toLowerCase();
const filtered = sorted.filter(p => p.cedula.toLowerCase().includes(term));
updateResults(filtered);
});
};
updateResults(sorted); // Inicializar
};
const getStatusClass = (p) => {
const isPrivado = p.condicion.toLowerCase().includes('privado de libertad');
return isPrivado ? 'privado-libertad' :
p.condicion.toLowerCase() !== 'no posee novedades actualmente' ?
'not-recommended' : 'recommended';
};
const simulateProgress = async () => {
await helpers.delay(300);
const aiMessages = [
{ text: "⚡ Inicializando módulos de IA...", audio: "inicio_ia" },
{ text: "🔍 Analizando estructura de datos...", audio: "analizando_datos" },
{ text: "🌐 Conectando a la base de datos neural...", audio: "conexion_servidor" },
{ text: "📊 Evaluando métricas de desempeño...", audio: "metricas_desempeno" },
{ text: "🧠 Procesando patrones de conducta...", audio: "patrones_conducta" },
{ text: "📈 Calculando índices de mérito...", audio: "calculo_merito" },
{ text: "🤖 Aplicando modelos predictivos...", audio: "modelos_predictivos" },
{ text: "🔗 Cruzando datos con registros disciplinarios...", audio: "registros_disciplinarios" },
{ text: "🎯 Optimizando coeficientes de ponderación...", audio: "optimizacion_parametros" },
{ text: "🧮 Realizando cálculos finales...", audio: "calculos_finales" },
{ text: "🖨️ Preparando reporte ejecutivo...", audio: "generacion_reporte" },
{ text: "✅ Proceso completado exitosamente", audio: "proceso_completado" }
];
const playEffect = () => {
const beep = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA=");
beep.volume = 0.2;
beep.play().catch(() => {});
};
const dotsContainer = document.createElement("div");
dotsContainer.style.cssText = "position:absolute;width:100%;height:100%;top:0;left:0;pointer-events:none;";
elements.loadingOverlay.appendChild(dotsContainer);
const createFloatingDot = () => {
const dot = document.createElement("div");
dot.style.cssText = `
position:absolute;
width:${8 + Math.random() * 4}px;
height:${8 + Math.random() * 4}px;
background:rgba(${100 + Math.random() * 100},255,150,0.4);
border-radius:50%;
left:${Math.random() * 95}%;
top:${Math.random() * 95}%;
opacity:0;
transition:opacity 0.4s ease, transform 2.5s ease;
`;
dotsContainer.appendChild(dot);
requestAnimationFrame(() => {
dot.style.opacity = "1";
dot.style.transform = `translate(${Math.random() * 60 - 30}px, ${Math.random() * 60 - 30}px) scale(1.5)`;
});
setTimeout(() => dot.remove(), 2000);
};
const dotsInterval = setInterval(createFloatingDot, 300);
try {
const total = aiMessages.length;
for (let i = 0; i < total; i++) {
const { text, audio } = aiMessages[i];
helpers.updateLoadingMessage(text);
playEffect();
const duration = await helpers.speak(audio) || 1;
const targetProgress = ((i + 1) / total) * 100;
const initialProgress = parseFloat(elements.progressBar.style.width) || 0;
let startTime = Date.now();
// Progreso y audio en paralelo
await Promise.race([
new Promise(resolve => {
const animate = () => {
const elapsed = (Date.now() - startTime) / 1000;
const progress = Math.min(
initialProgress + (elapsed / duration) * (targetProgress - initialProgress),
targetProgress
);
helpers.updateProgress(progress);
if (progress < targetProgress) {
requestAnimationFrame(animate);
} else {
resolve();
}
};
animate();
}),
helpers.delay((duration + 0.3) * 1000) // Buffer por si audio falla
]);
}
helpers.updateProgress(100);
helpers.updateLoadingMessage("✅ Proceso completado exitosamente");
elements.loadingMessage.classList.add("active");
elements.progressBar.style.boxShadow = "0 0 10px 2px #00ffa3";
await helpers.delay(1500);
elements.progressBar.style.boxShadow = "";
} catch (error) {
console.error("Error en el proceso:", error);
showNotification(`Error: ${error.message}`, "error");
} finally {
clearInterval(dotsInterval);
dotsContainer.remove();
if (state.currentAudio) {
state.currentAudio.pause();
state.currentAudio.currentTime = 0;
}
}
};
const showNotification = (mensaje, tipo = "error") => {
const notificacion = document.createElement("div");
notificacion.className = `notificacion ${tipo}`;
notificacion.innerHTML = `
<span class="notificacion-icono">${
tipo === "error" ? "❌" :
tipo === "success" ? "✅" : ""
}</span>
${mensaje}
`;
document.body.appendChild(notificacion);
setTimeout(() => notificacion.remove(), 5000);
};
async function iniciarBootSequence() {
const bootSteps = [
{
text: "🎙️ Iniciando generador de Procesos...",
audio: "boot_inicio_sistema"
},
{
text: "🧠 Activando núcleo cognitivo...",
audio: "boot_conexion_nucleo"
},
{
text: "⚡Cargando protocolos de integridad y seguridad...",
audio: "boot_sincronizacion_parametros"
},
{
text: "💾 Accediendo a módulos confidenciales...",
audio: "boot_preparando_base"
},
{
text: "✅ Autenticación exitosa...",
audio: "boot_acceso_autorizado"
},
];
const bootMessageEl = document.getElementById("bootMessage");
let current = 0;
const playStep = async () => {
const step = bootSteps[current];
if (!step) {
document.getElementById("bootScreen").classList.add("fade-out");
setTimeout(() => {
document.getElementById("bootScreen").remove(); // Elimina boot
endBootScreen(); // 🚀 Llamamos aquí el paso final: overlay + voz + carga
}, 1000); // Duración del fade-out
return;
}
// Mostrar texto con animación
bootMessageEl.classList.remove("active");
await new Promise(res => setTimeout(res, 300));
bootMessageEl.textContent = step.text;
bootMessageEl.classList.add("active");
// Reproducir audio
try {
const path = await window.electronAPI.getAudioPath(step.audio);
const audio = new Audio(path);
audio.volume = 0.8;
await new Promise(resolve => {
audio.onended = resolve;
audio.onerror = resolve;
audio.play().catch(resolve);
});
} catch (e) {
console.warn("No se pudo cargar audio:", step.audio);
}
current++;
setTimeout(playStep, 500);
};
setTimeout(playStep, 1000);
}
function endBootScreen() {
const overlay = document.getElementById("importOverlay");
overlay.style.display = "block";
helpers.showLoading();
helpers.updateLoadingMessage("Buscando archivo Excel...");
const audioKey = "excel_datos"; // o "dame_los_datos"
window.electronAPI.getAudioPath(audioKey).then((ruta) => {
if (!ruta) {
console.warn("❌ Ruta de audio no encontrada:", audioKey);
handleFileImport();
return;
}
const audio = new Audio(ruta);
audio.onended = () => {
handleFileImport();
};
audio.play().catch((err) => {
console.warn("⚠️ Error al reproducir audio:", err);
handleFileImport();
});
});
}
// Inicializar gráficos
const charts = {
recomendado: null,
noRecomendados: null
};
function initializeCharts() {
if (typeof Chart === 'undefined') {
console.error('Chart.js no está cargado');
return;
}
const ctx1 = document.getElementById('chartRecomendado');
const ctx2 = document.getElementById('chartNoRecomendados');
if (!ctx1 || !ctx2) {
console.error('Elementos del gráfico no encontrados');
return;
}
// Destruir gráficos existentes
if (charts.recomendado) charts.recomendado.destroy();
if (charts.noRecomendados) charts.noRecomendados.destroy();
// Gráfico de recomendados (Dona)
charts.recomendado = new Chart(ctx1, {
type: 'doughnut',
data: {
labels: ['Recomendados', 'No Recomendados'],
datasets: [{
data: [0, 0],
backgroundColor: ['#4CAF50', '#f44336']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' }
}
}
});
// Gráfico de no recomendados (Barras)
charts.noRecomendados = new Chart(ctx2, {
type: 'bar',
data: {
labels: ['No Recomendados'],
datasets: [{
label: 'Cantidad',
data: [0],
backgroundColor: '#f44336'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true }
}
}
});
}
function updateCharts(profesionales) {
if (!charts.recomendado || !charts.noRecomendados) {
console.warn('Gráficos no inicializados');
return;
}
const recomendados = profesionales.filter(p =>
p.condicion.toLowerCase() === 'no posee novedades actualmente'
).length;
const noRecomendados = profesionales.length - recomendados;
// Actualizar gráfico de dona
charts.recomendado.data.datasets[0].data = [recomendados, noRecomendados];
charts.recomendado.update();
// Actualizar gráfico de barras
charts.noRecomendados.data.datasets[0].data = [noRecomendados];
charts.noRecomendados.update();
}
window.addEventListener("DOMContentLoaded", () => {
iniciarBootSequence(); // ¡Arranca el boot!
setupEventListeners(); // Ya lo tenés
initializeCharts(); // Inicializar al cargar
});
});

738
src/styles.css Normal file
View File

@ -0,0 +1,738 @@
:root {
--primary-color: #3498db;
--secondary-color: #2c3e50;
--accent-color: #f1c40f;
--success-color: #2ecc71;
--danger-color: #e74c3c;
--light-color: #ecf0f1;
--dark-color: #34495e;
--recommended-color: #4CAF50;
--not-recommended-color: #f44336;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f7fa;
color: #333;
line-height: 1.6;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
width: 100%;
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
.app-header {
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
color: white;
padding: 20px;
border-radius: 10px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin: 0;
}
.app-header h1 {
font-size: 2rem;
margin-bottom: 8px;
line-height: 1.2;
}
.app-header p {
font-size: 1.2rem;
opacity: 0.9;
}
.control-panel {
display: flex;
justify-content: space-between;
gap: 15px;
padding: 10px 0;
flex-wrap: wrap;
}
.button-group {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
white-space: nowrap;
}
.import-btn {
background-color: var(--primary-color);
color: white;
}
.import-btn:hover {
background-color: #2980b9;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.generate-btn {
background-color: var(--success-color);
color: white;
}
.generate-btn:hover {
background-color: #27ae60;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.generate-btn:disabled {
background-color: #95a5a6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.data-container {
flex: 1;
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
width: 100%;
min-height: 0; /* Importante para contener el contenido */
height: 100%;
}
/* Sección de tabla */
.table-section {
background: #fff;
border-radius: 10px;
padding: 15px;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
/* Sección de gráficos */
.charts-section {
display: flex;
flex-direction: column;
gap: 15px;
height: 100%;
min-height: 0;
position: relative;
}
/* Tarjetas de gráficos */
.chart-box {
background: #fff;
padding: 12px;
border-radius: 10px;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
height: 50%; /* Ajustar altura */
min-height: 180px;
}
canvas {
max-height: 300px !important; /* Limitar altura máxima */
width: 100% !important;
}
/* Contenedor de la tabla con scroll */
.table-wrapper {
flex: 1;
overflow-y: auto;
margin-top: 10px;
max-height: calc(100vh - 300px); /* Altura máxima basada en viewport */
}
/* Asegurar que la tabla no desborde */
table {
width: 100%;
border-collapse: collapse;
font-size: 1rem;
table-layout: fixed; /* Evita cambios de tamaño con muchos datos */
}
th, td {
padding: 15px;
text-align: center;
border-bottom: 1px solid #ddd;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
th {
background-color: var(--primary-color);
color: white;
position: sticky;
top: 0;
font-weight: 600;
z-index: 1;
}
tr:nth-child(even) {
background-color: #f8f9fa;
}
tr:hover {
background-color: #e8f4fc;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: radial-gradient(circle, rgba(20,30,40,0.9), rgba(10,10,20,0.95));
backdrop-filter: blur(6px);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.4s ease;
}
.loading-overlay.active {
opacity: 1;
pointer-events: all;
}
#loadingMessage {
font-size: 1.2rem;
font-weight: 600;
text-align: center;
color: #fff;
opacity: 0;
transform: translateY(10px) scale(0.98);
transition: all 0.4s ease;
}
#loadingMessage.active {
opacity: 1;
transform: translateY(0) scale(1.02);
}
.progress-container {
width: 100%;
height: 10px;
background-color: #ecf0f1;
border-radius: 5px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(to right, #00ffa3, #5e60ce);
border-radius: 10px;
width: 0%;
transition: width 0.6s ease-in-out;
}
.results-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1001;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.results-modal.active {
opacity: 1;
pointer-events: all;
}
.results-content {
background-color: white;
border-radius: 10px;
width: 95%;
max-width: 1500px;
max-height: 90vh;
overflow-y: auto;
padding: 30px;
position: relative;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
}
.close-btn {
position: absolute;
top: 15px;
right: 15px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--danger-color);
color: white;
border: none;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.close-btn:hover {
transform: rotate(90deg);
background-color: #c0392b;
}
.card-layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.prof-card {
display: grid;
grid-template-columns: 70px 1fr 130px;
align-items: start;
gap: 1.5rem;
padding: 1.5rem;
border-left: 6px solid;
background: white;
border-radius: 10px;
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
position: relative;
}
.prof-card.recommended {
border-color: var(--recommended-color);
background: linear-gradient(to right, #f8fff8 0%, #ffffff 15%);
}
.prof-card.not-recommended {
border-color: var(--not-recommended-color);
background: linear-gradient(to right, #fff0f0 0%, #ffffff 15%);
}
.position-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.position-badge {
background: var(--primary-color);
color: white;
width: 50px;
height: 50px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.name-section {
flex-grow: 1;
}
.prof-name {
font-size: 1.5rem;
font-weight: 700;
color: var(--secondary-color);
margin-bottom: 0.3rem;
}
.prof-details {
display: flex;
gap: 1.5rem;
color: #666;
font-size: 0.9rem;
}
.medal-container {
font-size: 2.5rem;
margin: 0 1rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 2rem;
margin: 1rem 0;
}
.stat-pair {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem;
background: #f8f9fa;
border-radius: 8px;
}
.stat-label {
color: #666;
font-size: 0.9rem;
font-weight: 500;
text-transform: uppercase;
}
.stat-value {
font-size: 1.1rem;
font-weight: 600;
color: var(--secondary-color);
}
.result-container {
text-align: right;
}
.result-percentage {
font-size: 2.5rem;
font-weight: 800;
color: var(--primary-color);
line-height: 1;
}
.condition-container {
margin-top: 1rem;
padding: 0.8rem;
border-radius: 6px;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.condition-container.recommended {
background: #e8f5e9;
color: #2e7d32;
}
.condition-container.not-recommended {
background: #ffebee;
color: #d32f2f;
}
.privado-libertad {
order: 999;
border-color: #d32f2f;
background: linear-gradient(to right, #ffcdd2 0%, #ffffff 15%);
}
/* Leyenda mejorada */
.legend {
display: flex;
gap: 2rem;
margin: 1.5rem 0;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 1rem;
border-radius: 8px;
background: #f8f9fa;
}
.color-box {
width: 25px;
height: 25px;
border-radius: 5px;
}
.color-box.recommended {
background: var(--recommended-color);
}
.color-box.not-recommended {
background: var(--not-recommended-color);
}
@media (max-width: 768px) {
.prof-card {
grid-template-columns: 1fr;
gap: 1rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat-pair {
flex-direction: column;
align-items: flex-start;
}
.result-container {
text-align: left;
}
}
/* Modificar estos estilos */
.modal-actions {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 30px;
}
.pdf-btn {
background-color: var(--danger-color);
color: white;
padding: 12px 25px;
border-radius: 8px;
transition: all 0.3s ease;
}
.pdf-btn:hover {
background-color: #c0392b;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
/* Espacio adicional para no recomendados */
.results-modal h2[title="🚫 Personal No Recomendado"] {
margin-bottom: 1.5rem;
}
.results-modal .card-layout {
margin-top: 1.2rem;
}
@media (max-width: 1200px) {
.dashboard-layout {
grid-template-columns: 1fr;
}
.chart-column {
position: static;
}
}
/* Nuevas clases */
.extra-margin-bottom { margin-bottom: 1.5rem !important; }
.results-margin-top { margin-top: 1.2rem !important; }
#loadingMessage {
transition: all 0.4s ease;
opacity: 0;
}
#loadingMessage.active {
opacity: 1;
transform: scale(1.02);
}
#bootScreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle, #0f2027, #203a43, #2c5364);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
color: #fff;
animation: fadeIn 1s ease forwards;
}
.boot-content {
text-align: center;
animation: pulse 3s ease-in-out infinite;
}
.logo-glow {
font-size: 3.5rem;
font-weight: bold;
letter-spacing: 4px;
color: #00ffc8;
text-shadow: 0 0 15px #00ffc8, 0 0 30px #00ffc8;
}
.boot-subtitle {
font-size: 1.2rem;
margin-top: 15px;
color: #b2ebf2;
}
.boot-message {
margin-top: 30px;
font-size: 1rem;
font-family: monospace;
opacity: 0.8;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.02); opacity: 0.9; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
#bootScreen.fade-out {
animation: fadeOut 1s ease forwards;
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
.search-container {
margin: 15px 0;
padding: 0 20px;
}
.search-input {
width: 100%;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 25px;
font-size: 0.9rem;
outline: none;
transition: all 0.3s;
}
.search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 8px rgba(52, 152, 219, 0.2);
}
#importOverlay {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.85);
padding: 30px 40px;
border-radius: 20px;
color: #00ffcc;
font-size: 24px;
text-align: center;
box-shadow: 0 0 20px #00ffcc;
animation: aparecer 1s ease-out forwards;
}
@keyframes aparecer {
from {
opacity: 0;
transform: translate(-50%, -60%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.title-wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
padding: 30px;
background-color: rgba(0, 0, 0, 0.3); /* opcional, para destacar */
border-radius: 20px;
}
.logo-title {
width: 80px;
height: 80px;
object-fit: contain;
animation: float-glow 3s ease-in-out infinite;
filter: drop-shadow(0 0 10px var(--primary-color));
border-radius: 50%;
background: radial-gradient(circle, #00ffc8 20%, transparent 80%);
padding: 5px;
box-shadow: 0 0 15px #00ffc8, 0 0 40px rgba(0, 255, 200, 0.4);
}
.text-wrapper h1 {
font-size: 2.2rem;
color: #ffffff; /* Blanco puro */
text-shadow: 0 0 15px #ffffff, 0 0 30px #aaa; /* Glow blanco */
margin: 0;
}
.text-wrapper p {
font-size: 1.1rem;
color: #bbbbbb; /* Gris clarito con buen contraste */
font-weight: bold;
margin: 5px 0 0 0;
text-shadow: 0 0 5px rgba(255, 255, 255, 0.1);
}
/* Efecto flotante para el logo */
@keyframes float-glow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}

BIN
src/views/SamiPro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

129
src/views/main.html Normal file
View File

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self' data:;" />
<title>Sistema de Orden de Mérito</title>
<link rel="stylesheet" href="../styles.css" />
</head>
<body>
<!-- BootScreen -->
<div id="bootScreen">
<div class="boot-content">
<div class="logo-glow">S.A.M.I.</div>
<p class="boot-subtitle">Sistema de Análisis Meritocrático Inteligente</p>
<p class="boot-message" id="bootMessage">Iniciando módulos de IA...</p>
</div>
</div>
<div id="importOverlay" style="display:none;">
<!-- <p>¡por favor los datos a procesar!</p> -->
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Container 1: Título -->
<div class="app-container">
<header class="app-header">
<div class="title-wrapper">
<img src="sami.png" alt="Logo S.A.M.I." class="logo-title">
<div class="text-wrapper">
<h1>Sistema de Análisis Meritocrático Inteligente 🏆</h1>
<p>Generación automática de Orden de Mérito para Ascensos</p>
</div>
</div>
</header>
</div>
<!-- Container 2: Botones -->
<div class="control-panel">
<div class="button-group left-group">
<button id="importBtn" class="action-btn import-btn">
<span class="btn-icon">📁</span> Importar Excel
</button>
</div>
<div class="button-group right-group">
<button id="audioToggle" class="action-btn">🔊 Audio ON</button>
<button id="generateBtn" class="action-btn generate-btn" disabled>
<span class="btn-icon">🏅</span> Generar Mérito
</button>
<button id="nonRecommendedBtn" class="action-btn nonrecommended-btn">
<span class="btn-icon">🚫</span> No Recomendados
</button>
</div>
</div>
<!-- Container 3 y 4: Tabla + Gráficos -->
<div class="data-container">
<!-- Container 3: Tabla -->
<div class="table-section">
<h2>📋 Lista de Profesionales</h2>
<div class="table-wrapper">
<table id="previewTable">
<thead>
<tr>
<th></th>
<th>Grado</th>
<th>Nombre Completo</th>
<th>Cedula</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- Container 4: Gráficos -->
<div class="charts-section">
<div class="chart-box">
<h3>📊 Distribución por Recomendados y Condiciones</h3>
<canvas id="chartRecomendado"></canvas>
</div>
<div class="chart-box">
<h3>📈 Total de profesional con una condición</h3>
<canvas id="chartNoRecomendados"></canvas>
</div>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loadingOverlay" class="loading-overlay">
<div class="loading-content">
<div class="spinner"></div>
<div id="loadingMessage" class="loading-message">Procesando datos...</div>
<div class="progress-container">
<div id="progressBar" class="progress-bar"></div>
</div>
</div>
</div>
<!-- Results Modal -->
<div id="resultsModal" class="results-modal">
<div class="results-content">
<button id="closeResults" class="close-btn">&times;</button>
<h2>🏅 Resultados del Orden de Mérito</h2>
<button id="savePdfBtn" class="action-btn pdf-btn">
💾 Guardar PDF
</button>
<div id="resultsContainer" class="results-container"></div>
<div class="modal-actions">
</div>
</div>
</div>
<!-- Script principal -->
<script src="../js/renderer.js"></script>
<script src="../../node_modules/chart.js/dist/chart.umd.js"></script>
</body>
</html>

BIN
src/views/sami.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB