primer commit del README
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
|
||||
out/
|
||||
106
README.md
Normal 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
|
After Width: | Height: | Size: 307 KiB |
BIN
assets/imagen1.ico
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/imagen1.png
Normal file
|
After Width: | Height: | Size: 442 KiB |
BIN
assets/imagen2.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/sami_icon.ico
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
data/data1.xlsx
Normal file
227
database.js
Normal 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
@ -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
@ -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
@ -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
77
package.json
Normal 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
@ -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
@ -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)
|
||||
|
||||
});
|
||||
|
||||
|
||||
BIN
src/audios/analizando_datos.mp3
Normal file
BIN
src/audios/boot_acceso_autorizado.mp3
Normal file
BIN
src/audios/boot_conexion_nucleo.mp3
Normal file
BIN
src/audios/boot_inicio_sistema.mp3
Normal file
BIN
src/audios/boot_preparando_base.mp3
Normal file
BIN
src/audios/boot_sincronizacion_parametros.mp3
Normal file
BIN
src/audios/calculo_merito.mp3
Normal file
BIN
src/audios/calculos_finales.mp3
Normal file
BIN
src/audios/conexion_servidor.mp3
Normal file
BIN
src/audios/excel_datos.mp3
Normal file
BIN
src/audios/generacion_reporte.mp3
Normal file
BIN
src/audios/inicio_ia.mp3
Normal file
BIN
src/audios/metricas_desempeno.mp3
Normal file
BIN
src/audios/modelos_predictivos.mp3
Normal file
BIN
src/audios/optimizacion_parametros.mp3
Normal file
BIN
src/audios/patrones_conducta.mp3
Normal file
BIN
src/audios/procesamiento_completado.mp3
Normal file
BIN
src/audios/proceso_completado.mp3
Normal file
BIN
src/audios/registros_disciplinarios.mp3
Normal file
BIN
src/fonts/Helvetica-Bold.ttf
Normal file
BIN
src/fonts/Helvetica.ttf
Normal file
92
src/js/boot.js
Normal 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
@ -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
@ -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
@ -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
|
After Width: | Height: | Size: 307 KiB |
129
src/views/main.html
Normal 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>N°</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">×</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
|
After Width: | Height: | Size: 1.7 MiB |