feat: implement Flask backend with SQLite database and duplicate detection logic for personal management
This commit is contained in:
commit
9cc12d414f
0
app_dump.txt
Normal file
0
app_dump.txt
Normal file
BIN
parte_numerico/__pycache__/app.cpython-314.pyc
Normal file
BIN
parte_numerico/__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
337
parte_numerico/app.py
Normal file
337
parte_numerico/app.py
Normal file
@ -0,0 +1,337 @@
|
||||
import sqlite3
|
||||
from flask import Flask, jsonify, request, render_template
|
||||
from datetime import datetime, date
|
||||
|
||||
app = Flask(__name__)
|
||||
DB_NAME = 'datos.db'
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_NAME, timeout=15.0, check_same_thread=False)
|
||||
conn.execute('pragma journal_mode=wal') # Write-Ahead Logging is great for concurrency
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
with app.app_context():
|
||||
conn = get_db()
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS personal (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cedula TEXT UNIQUE NOT NULL,
|
||||
apellidos_nombres TEXT NOT NULL,
|
||||
grado TEXT NOT NULL,
|
||||
categoria TEXT NOT NULL,
|
||||
estatus_actual TEXT DEFAULT 'Disponible',
|
||||
fecha_salida TEXT,
|
||||
fecha_llegada TEXT,
|
||||
orden INTEGER DEFAULT 0
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS estatus (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nombre TEXT UNIQUE NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
count = conn.execute('SELECT COUNT(*) FROM estatus').fetchone()[0]
|
||||
if count == 0:
|
||||
defaults = ["Disponible", "P.D.S.C.", "F.T.C.", "Reposo", "Permiso Extraordinario", "Permiso Operacional", "Comisión", "A/O", "Servicio"]
|
||||
for d in defaults:
|
||||
conn.execute('INSERT INTO estatus (nombre) VALUES (?)', (d,))
|
||||
conn.commit()
|
||||
|
||||
# Migration: add new columns if they don't exist
|
||||
cursor = conn.execute("PRAGMA table_info(personal)")
|
||||
columns = [row['name'] for row in cursor.fetchall()]
|
||||
if 'fecha_salida' not in columns:
|
||||
conn.execute('ALTER TABLE personal ADD COLUMN fecha_salida TEXT')
|
||||
if 'fecha_llegada' not in columns:
|
||||
conn.execute('ALTER TABLE personal ADD COLUMN fecha_llegada TEXT')
|
||||
if 'orden' not in columns:
|
||||
conn.execute('ALTER TABLE personal ADD COLUMN orden INTEGER DEFAULT 0')
|
||||
# Initialize orden based on existing IDs
|
||||
conn.execute('UPDATE personal SET orden = id WHERE orden = 0 OR orden IS NULL')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
init_db()
|
||||
|
||||
def normalize_name(name):
|
||||
# Sorts words alphabetically to detect inverted names like JORGE LOPEZ vs LOPEZ JORGE
|
||||
words = [w.strip() for w in str(name).upper().split() if w.strip()]
|
||||
return ' '.join(sorted(words))
|
||||
|
||||
def is_duplicate_name(conn, new_name, exclude_id=None):
|
||||
norm_new = normalize_name(new_name)
|
||||
cursor = conn.execute('SELECT id, apellidos_nombres FROM personal')
|
||||
for row in cursor.fetchall():
|
||||
if exclude_id and row['id'] == exclude_id:
|
||||
continue
|
||||
if normalize_name(row['apellidos_nombres']) == norm_new:
|
||||
return True
|
||||
return False
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/api/personal', methods=['GET'])
|
||||
def get_all_personal():
|
||||
conn = get_db()
|
||||
cursor = conn.execute('SELECT * FROM personal ORDER BY categoria, orden, grado, apellidos_nombres')
|
||||
personal = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return jsonify(personal)
|
||||
|
||||
@app.route('/api/personal', methods=['POST'])
|
||||
def add_personal():
|
||||
data = request.json
|
||||
try:
|
||||
conn = get_db()
|
||||
if is_duplicate_name(conn, data['apellidos_nombres']):
|
||||
conn.close()
|
||||
return jsonify({'error': 'Ya existe una persona registrada con esos nombres (posible duplicado invertido)'}), 400
|
||||
|
||||
# Get next orden value
|
||||
cursor = conn.execute('SELECT COALESCE(MAX(orden), 0) + 1 as next_orden FROM personal')
|
||||
next_orden = cursor.fetchone()['next_orden']
|
||||
cursor = conn.execute(
|
||||
'INSERT INTO personal (cedula, apellidos_nombres, grado, categoria, estatus_actual, orden, antiguedad) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
(data['cedula'], data['apellidos_nombres'], data['grado'], data['categoria'], data.get('estatus_actual', 'Disponible'), next_orden, data.get('antiguedad', 0))
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return jsonify({'message': 'Personal registrado', 'id': new_id}), 201
|
||||
except sqlite3.IntegrityError:
|
||||
return jsonify({'error': 'La cédula ya está registrada'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/personal/bulk', methods=['POST'])
|
||||
def add_personal_bulk():
|
||||
data = request.json
|
||||
conn = get_db()
|
||||
count = 0
|
||||
skipped = 0
|
||||
try:
|
||||
cursor = conn.execute('SELECT COALESCE(MAX(orden), 0) as max_orden FROM personal')
|
||||
current_orden = cursor.fetchone()['max_orden']
|
||||
for p in data:
|
||||
try:
|
||||
if 'cedula' in p and 'apellidos_nombres' in p and 'grado' in p and 'categoria' in p:
|
||||
if is_duplicate_name(conn, p.get('apellidos_nombres', '')):
|
||||
skipped += 1
|
||||
continue
|
||||
current_orden += 1
|
||||
conn.execute(
|
||||
'INSERT INTO personal (cedula, apellidos_nombres, grado, categoria, estatus_actual, orden) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(str(p.get('cedula', '')).strip(), str(p.get('apellidos_nombres', '')).strip().upper(), p.get('grado', '').strip().upper(), p.get('categoria', '').strip(), p.get('estatus_actual', 'Disponible'), current_orden)
|
||||
)
|
||||
count += 1
|
||||
except sqlite3.IntegrityError:
|
||||
skipped += 1
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
msg = f'{count} registros importados correctamente.'
|
||||
if skipped > 0:
|
||||
msg += f' Se omitieron {skipped} por nombres o cédulas duplicadas.'
|
||||
return jsonify({'message': msg}), 201
|
||||
|
||||
@app.route('/api/personal/<int:id>', methods=['PUT'])
|
||||
def update_personal(id):
|
||||
data = request.json
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
|
||||
# Check duplicate name if updating name
|
||||
if 'apellidos_nombres' in data:
|
||||
if is_duplicate_name(conn, data['apellidos_nombres'], exclude_id=id):
|
||||
conn.close()
|
||||
return jsonify({'error': 'Ese nombre ya está registrado en otra persona (verifique posibles duplicados o nombres invertidos)'}), 400
|
||||
|
||||
update_fields = []
|
||||
params = []
|
||||
|
||||
for key in ['cedula', 'apellidos_nombres', 'grado', 'categoria', 'estatus_actual', 'fecha_salida', 'fecha_llegada', 'orden', 'antiguedad']:
|
||||
if key in data:
|
||||
update_fields.append(f'{key} = ?')
|
||||
params.append(data[key])
|
||||
|
||||
# If status changed to Disponible, clear dates
|
||||
if data.get('estatus_actual') == 'Disponible':
|
||||
if 'fecha_salida' not in data:
|
||||
update_fields.append('fecha_salida = ?')
|
||||
params.append(None)
|
||||
if 'fecha_llegada' not in data:
|
||||
update_fields.append('fecha_llegada = ?')
|
||||
params.append(None)
|
||||
|
||||
if not update_fields:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
params.append(id)
|
||||
|
||||
cursor = conn.execute(f'UPDATE personal SET {", ".join(update_fields)} WHERE id = ?', params)
|
||||
conn.commit()
|
||||
if cursor.rowcount == 0:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Registro no encontrado'}), 404
|
||||
conn.close()
|
||||
return jsonify({'message': 'Personal actualizado satisfactoriamente'})
|
||||
except sqlite3.IntegrityError:
|
||||
return jsonify({'error': 'Error de integridad (posible cédula duplicada)'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/personal/<int:id>', methods=['DELETE'])
|
||||
def delete_personal(id):
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.execute('DELETE FROM personal WHERE id = ?', (id,))
|
||||
conn.commit()
|
||||
rows_deleted = cursor.rowcount
|
||||
conn.close()
|
||||
if rows_deleted:
|
||||
return jsonify({'message': 'Personal eliminado satisfactoriamente'})
|
||||
return jsonify({'error': 'Registro no encontrado'}), 404
|
||||
except Exception as e:
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/personal/reorder', methods=['POST'])
|
||||
def reorder_personal():
|
||||
"""Swap the orden of two personnel records"""
|
||||
data = request.json
|
||||
id1 = data.get('id1')
|
||||
id2 = data.get('id2')
|
||||
if not id1 or not id2:
|
||||
return jsonify({'error': 'Se requieren id1 e id2'}), 400
|
||||
try:
|
||||
conn = get_db()
|
||||
row1 = conn.execute('SELECT orden FROM personal WHERE id = ?', (id1,)).fetchone()
|
||||
row2 = conn.execute('SELECT orden FROM personal WHERE id = ?', (id2,)).fetchone()
|
||||
if not row1 or not row2:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Registro no encontrado'}), 404
|
||||
conn.execute('UPDATE personal SET orden = ? WHERE id = ?', (row2['orden'], id1))
|
||||
conn.execute('UPDATE personal SET orden = ? WHERE id = ?', (row1['orden'], id2))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Orden actualizado'})
|
||||
except Exception as e:
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/personal/by-status', methods=['GET'])
|
||||
def get_personal_by_status():
|
||||
"""Get all personnel grouped by status for PDF generation"""
|
||||
conn = get_db()
|
||||
cursor = conn.execute('SELECT * FROM personal ORDER BY categoria, orden, grado, apellidos_nombres')
|
||||
personal = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
|
||||
grouped = {}
|
||||
for p in personal:
|
||||
status = p['estatus_actual']
|
||||
if status not in grouped:
|
||||
grouped[status] = []
|
||||
grouped[status].append(p)
|
||||
return jsonify(grouped)
|
||||
|
||||
@app.route('/api/dashboard', methods=['GET'])
|
||||
def get_dashboard():
|
||||
conn = get_db()
|
||||
cursor = conn.execute('SELECT categoria, estatus_actual, COUNT(*) as cantidad FROM personal GROUP BY categoria, estatus_actual')
|
||||
rows = cursor.fetchall()
|
||||
|
||||
dashboard = {
|
||||
'Oficiales Generales': {'Personal': 0, 'Disponible': 0, 'Falta': 0, 'DetalleFaltas': {}},
|
||||
'Oficiales Superiores': {'Personal': 0, 'Disponible': 0, 'Falta': 0, 'DetalleFaltas': {}},
|
||||
'Oficiales Subalternos': {'Personal': 0, 'Disponible': 0, 'Falta': 0, 'DetalleFaltas': {}},
|
||||
'Tropa Profesional': {'Personal': 0, 'Disponible': 0, 'Falta': 0, 'DetalleFaltas': {}},
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
cat = row['categoria']
|
||||
estado = row['estatus_actual']
|
||||
cant = row['cantidad']
|
||||
|
||||
if cat not in dashboard:
|
||||
continue
|
||||
|
||||
dashboard[cat]['Personal'] += cant
|
||||
if estado == 'Disponible':
|
||||
dashboard[cat]['Disponible'] += cant
|
||||
else:
|
||||
dashboard[cat]['Falta'] += cant
|
||||
dashboard[cat]['DetalleFaltas'][estado] = dashboard[cat]['DetalleFaltas'].get(estado, 0) + cant
|
||||
|
||||
conn.close()
|
||||
return jsonify(dashboard)
|
||||
|
||||
@app.route('/api/estatus', methods=['GET'])
|
||||
def get_estatus():
|
||||
conn = get_db()
|
||||
cursor = conn.execute('SELECT id, nombre FROM estatus ORDER BY id')
|
||||
res = [dict(r) for r in cursor.fetchall()]
|
||||
conn.close()
|
||||
return jsonify(res)
|
||||
|
||||
@app.route('/api/estatus', methods=['POST'])
|
||||
def add_estatus():
|
||||
data = request.json
|
||||
if not data or 'nombre' not in data:
|
||||
return jsonify({'error': 'Nombre es requerido'}), 400
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.execute('INSERT INTO estatus (nombre) VALUES (?)', (data['nombre'].strip(),))
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return jsonify({'message': 'Estatus agregado', 'id': new_id}), 201
|
||||
except sqlite3.IntegrityError:
|
||||
return jsonify({'error': 'El estatus ya existe'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/estatus/<int:id>', methods=['DELETE'])
|
||||
def delete_estatus(id):
|
||||
try:
|
||||
conn = get_db()
|
||||
row = conn.execute('SELECT nombre FROM estatus WHERE id = ?', (id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Estatus no encontrado'}), 404
|
||||
nombre_estatus = row['nombre']
|
||||
if nombre_estatus == 'Disponible':
|
||||
conn.close()
|
||||
return jsonify({'error': 'No se puede eliminar el estatus central "Disponible"'}), 400
|
||||
|
||||
# Reassign any personnel currently using this status back to Disponible
|
||||
conn.execute('UPDATE personal SET estatus_actual = "Disponible", fecha_salida = NULL, fecha_llegada = NULL WHERE estatus_actual = ?', (nombre_estatus,))
|
||||
|
||||
cursor = conn.execute('DELETE FROM estatus WHERE id = ?', (id,))
|
||||
conn.commit()
|
||||
rows_deleted = cursor.rowcount
|
||||
conn.close()
|
||||
if rows_deleted:
|
||||
return jsonify({'message': 'Estatus eliminado y personal reasignado a Disponible satisfactoriamente'})
|
||||
return jsonify({'error': 'No encontrado'}), 404
|
||||
except Exception as e:
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
BIN
parte_numerico/datos.db
Normal file
BIN
parte_numerico/datos.db
Normal file
Binary file not shown.
BIN
parte_numerico/dump.txt
Normal file
BIN
parte_numerico/dump.txt
Normal file
Binary file not shown.
98
parte_numerico/dump_names.txt
Normal file
98
parte_numerico/dump_names.txt
Normal file
@ -0,0 +1,98 @@
|
||||
48, 28273088, ACOSTA RIVAS MARIA JOSE
|
||||
70, 26049194, ALVAREZ COLMENAREZ JOSE
|
||||
11, 17818665, ANDRES RICARDO RODRIGUEZ DURAN
|
||||
29, 27974382, ANMARYS JOSELINE MONTAÑA CANELONES
|
||||
66, 26184818, ARMAS BELISARIO ALCIDES
|
||||
73, 12668838, BELLORIN URBINA MELVYN NEHIL
|
||||
85, 30447097, CAMACARO MORALES WILFREDO ANTONIO
|
||||
16, 19254679, CARLOS ALBERTO TERÁN LACAU
|
||||
65, 20649666, CASTELLANO CEBALLOS ANABEL YOSMARI
|
||||
77, 23248313, CESPEDES GAMEZ YOEL ELY
|
||||
69, 20657594, CHAPARRO ELIA ANDREINA
|
||||
89, 16349265, COLINA QUINTERO JOSÉ ROMÁN
|
||||
58, 26635924, CRUZ PALMAR PEDRO ANGEL
|
||||
32, 26573366, DANIEL ALEJANDRO HERNÁNDEZ SEGURA
|
||||
35, 29574607, DANIEL ALEXANDER FIGUEREDO ORTEGA
|
||||
56, 18054194, DURAN VAZQUEZ CANDY JOSEFINA
|
||||
4, 13548097, EDGAR JOSE PALACIOS HERNANDEZ
|
||||
39, 19426955, EDGARDO JOSÉ ARIAS LÓPEZ
|
||||
6, 18067423, EDREY E. COLMENARES HERNÁNDEZ
|
||||
37, 28011072, ELVI FERNANDO RODRIGUEZ GUZMÁN
|
||||
19, 19410542, ENMANUEL SANTIAGO MANZANO
|
||||
71, 29866230, ESCOBAR VILLAMIZAR FELIX DANIEL
|
||||
44, 18894914, FELIX JOSE ARIAS RUÍZ
|
||||
8, 14224051, FRANCISCO JARAMILLO DE LAS SALAS
|
||||
49, 21119311, GENESIS DAYKEL RODRIGUEZ TABLERO
|
||||
20, 21378756, GERARDO ENRIQUE RIVERO MENDOZA
|
||||
18, 21109925, GLORIMAR DELVALLE ZAPATA BARRERA
|
||||
93, 31280989, GUILLEN SEGURA GABRIEL ALFREDO
|
||||
52, 31360901, HECTOR JOSÉ DUARTE PACHECO
|
||||
38, 11078907, HENRY RIVERO RIVERO
|
||||
94, 30690773, HERNANDEZ HERNANDEZ MARCOS DAVID
|
||||
95, 33791323, HERNANDEZ LUIS DAVID
|
||||
90, 20839045, HERNÁNDEZ PASCAL ALEXANDER YONEY
|
||||
50, 19441350, IRVINNG ERNESTO GARCES ROSSELL
|
||||
72, 16473016, ISTURIZ PORTILLO VICTOR JOSÉ
|
||||
43, 18361908, JAISKELL ACOSTA BLANCO
|
||||
1, 14755977, JAVIER ERNESTO RODRIGUEZ MARCHAN
|
||||
34, 24641019, JEFERSON M IBARRA MONTEVERDE
|
||||
17, 23410861, JENIFER ALEJANDRA DA SILVA LINARES
|
||||
27, 25842263, JESSICA MASSIEL VARGAS PÉREZ
|
||||
22, 18405928, JESÚS EMILIO RAMOS MONROY
|
||||
9, 16425908, JOHAN ANTONIO SILVA APONTE
|
||||
51, 24501175, JOSE ANYELO RODRIGUEZ BAILLIE
|
||||
7, 13261873, JOSE DEL CARMEN ANDRADE CAPUANO
|
||||
55, 32297109, JOSE FELIX TERAN VILLAZANA
|
||||
10, 18715589, JOSE GUERRERO MARTINEZ
|
||||
28, 31624685, JOSÉ ANTONIO MENDOZA NÚÑEZ JOSÉ
|
||||
12, 14291520, JOSÉ LUIS MARCANO GARCÍA
|
||||
5, 15342493, JULIO ALEXANDER GRATEROL LÓPEZ
|
||||
21, 24930791, KEWIS YASER KIWAI OLIVEROS
|
||||
46, 26078726, KIMBERLY MARIA SANCHEZ MARTINEZ
|
||||
74, 16659635, LARROCHELLE MOCADAN JEAN MARCEL
|
||||
3, 13020126, LEONARDO FERNÁNDEZ GARCÍA
|
||||
104, 19594352, LOPEZ RAMOS JORGE FELIX
|
||||
53, 31359772, LUIS DANIEL OLIVEROS CANACHE
|
||||
14, 15482551, LUIS MARIANO MÉNDEZ SEGURA
|
||||
64, 26327108, MAITA GOMEZ AURIANA JUNIELA
|
||||
81, 25692156, MASTER SAAVEDRA RAUL ALEJANDRO
|
||||
87, 16383155, MEJICANO MONTILLA GLEN JESÚS
|
||||
80, 27215562, MENDEZ JIMENEZ JHONATAN JESUS
|
||||
91, 27123237, MENESES TERAN JOSE GREGORIO
|
||||
25, 18224757, MIGUEL ARCANGEL OLLARVES MAYORQUIN
|
||||
59, 26398925, MILIAN HERNANDEZ JESUS ALFREDO
|
||||
42, 16405839, MONICA ALEJANDRA ESPINOZA ROJAS
|
||||
84, 30690467, MORENO SILVA JOHAN DAVID
|
||||
82, 25780145, MOTA IBARRA VICTOR DAVID
|
||||
83, 29576720, NAVARRO CORALES DARWIN JOSE
|
||||
54, 31430862, NIXON DAVID MARTINEZ PAREJO
|
||||
47, 17155743, OMAR JESUS MORILLO JÍMENEZ
|
||||
24, 19357222, OMAR JOSE NAVAS ACOSTA
|
||||
88, 18995832, PACHECO ESPINOZA LARRY ELOY
|
||||
40, 15928384, PEDRO JOSÉ SANTANA MEZA
|
||||
76, 19788709, PEÑA EDINSON ALFREDO
|
||||
63, 27701798, PORTILLO CAMPOS LUIS ALEJANDRO
|
||||
15, 18854642, RAFAEL ARCANGEL RANGEL RUIZ
|
||||
75, 17371423, RAMIREZ YERFINSON BENITO
|
||||
103, 18776363, RAMOS RAUSEO YISSETLIS MARIA
|
||||
61, 14362549, RAYMONDI HERNÁNDEZ OSWALDO A.
|
||||
92, 30223046, REYES HERES FERNANDO ANTONIO
|
||||
86, 10709077, RIERA YÁNEZ JESÚS GUADALUPE
|
||||
67, 21099185, RIVAS CABALLERO JESICA MARLYN
|
||||
98, 25282803, RODRIGUEZ BASTARDO NORAIMA YOLIMAR
|
||||
60, 18529226, RODRÍGUEZ BONTEMPS JOHANA
|
||||
30, 24574200, RUBEN DANIEL LUGO OLLARVE
|
||||
23, 20870419, RUTH MILADY ARANGUREN COVA
|
||||
2, 13486815, SAÚL PASTOR SUÁREZ ÁLVAREZ
|
||||
57, 24157134, SIVIRA HERNÁNDEZ FRANCISCO JAVIER
|
||||
33, 29536739, SKARLET PATRICIA ARMAS ARMAS
|
||||
78, 24247434, SUAREZ HERNANDEZ ANTONY JOSE
|
||||
102, 9795711, SUAREZ RODRIGUEZ RAFAEL ANGEL
|
||||
26, 20018145, TONY NELSON DAGAMA
|
||||
79, 20871469, VALLADARES ARELLANO JESUS
|
||||
68, 21443290, VARGAS CORREA BRAYAN NEIDLINGER
|
||||
36, 28354604, YAILUIS GERMAIR FIGUEROA CASTILLO
|
||||
31, 23778523, YEISON SEGUNDO BRICEÑO GONZALEZ
|
||||
41, 17641108, YELITZA JUDITH SANTOS VELÁSQUEZ
|
||||
13, 19494115, YHOSVAM JOSE POLEO HERRERA
|
||||
45, 14880607, YINI JOSÉ BARON DURAN
|
||||
1
parte_numerico/requirements.txt
Normal file
1
parte_numerico/requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
Flask==3.0.0
|
||||
64
parte_numerico/static/css/styles.css
Normal file
64
parte_numerico/static/css/styles.css
Normal file
@ -0,0 +1,64 @@
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background-color: #1e293b; /* slate-800 */
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Custom minimal scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 1em;
|
||||
}
|
||||
|
||||
/* Smooth row transitions */
|
||||
tr {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Date input styling */
|
||||
input[type="date"] {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Pulse animation for overdue alerts */
|
||||
@keyframes pulse-slow {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 2s ease-in-out infinite;
|
||||
}
|
||||
1216
parte_numerico/static/js/app.js
Normal file
1216
parte_numerico/static/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
337
parte_numerico/templates/index.html
Normal file
337
parte_numerico/templates/index.html
Normal file
@ -0,0 +1,337 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Parte Numérico - Gestión Militar</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 text-gray-800 font-sans min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav id="main-nav" class="bg-slate-900 text-white shadow-lg sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="h-8 w-8 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span class="font-bold text-xl tracking-wide">Sistema Parte Numérico</span>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button id="tab-dashboard" onclick="switchTab('dashboard')"
|
||||
class="tab-btn px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-slate-700 bg-slate-800 text-white">Panel
|
||||
Control</button>
|
||||
<button id="tab-lista" onclick="switchTab('lista')"
|
||||
class="tab-btn px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-slate-700 text-gray-300">Pase
|
||||
de Lista</button>
|
||||
<button id="tab-gestion" onclick="switchTab('gestion')"
|
||||
class="tab-btn px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-slate-700 text-gray-300">Gestión</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- ==================== DASHBOARD SECTION ==================== -->
|
||||
<section id="section-dashboard" class="tab-content fade-in">
|
||||
<div id="dashboard-screen-header" class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-slate-800">Resumen Diario: Parte Numérico</h1>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="exportarExcel()"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded shadow-md transition-all flex items-center gap-2 font-medium text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
|
||||
</path>
|
||||
</svg>
|
||||
Exportar Excel
|
||||
</button>
|
||||
<button onclick="generatePDF()"
|
||||
class="bg-gray-800 hover:bg-black text-white px-4 py-2 rounded shadow-md transition-all flex items-center gap-2 font-medium text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2z">
|
||||
</path>
|
||||
</svg>
|
||||
Imprimir PDF
|
||||
</button>
|
||||
<button onclick="loadDashboard()"
|
||||
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded shadow-md transition-all flex items-center gap-2 font-medium text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15">
|
||||
</path>
|
||||
</svg>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Summary Table -->
|
||||
<div class="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200 mb-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-center">
|
||||
<thead class="bg-slate-800 text-white text-sm uppercase font-semibold">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left border-r border-slate-700">Situación</th>
|
||||
<th class="px-4 py-3 border-r border-slate-700">Ofic/Gral</th>
|
||||
<th class="px-4 py-3 border-r border-slate-700">Ofic/Sup</th>
|
||||
<th class="px-4 py-3 border-r border-slate-700">Ofic/Sub</th>
|
||||
<th class="px-4 py-3 border-r border-slate-700">TT/PP</th>
|
||||
<th class="px-4 py-3 bg-slate-900 text-emerald-400">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashboard-body" class="text-sm divide-y divide-gray-200">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alertas de Permisos Vencidos -->
|
||||
<div id="alerts-container" class="mb-6"></div>
|
||||
|
||||
<!-- Personnel Detail Cards by Status -->
|
||||
<div id="status-cards-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================== PASE DE LISTA SECTION ==================== -->
|
||||
<section id="section-lista" class="tab-content hidden fade-in">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-3">
|
||||
<h1 class="text-3xl font-bold text-slate-800">Pase de Lista</h1>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<div class="relative">
|
||||
<svg class="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<input type="text" id="lista-search" placeholder="Buscar cédula o nombre..."
|
||||
oninput="filterLista()"
|
||||
class="pl-8 pr-3 py-2 border border-gray-300 rounded-md text-sm w-56 focus:ring-emerald-500 focus:border-emerald-500 shadow-sm">
|
||||
</div>
|
||||
<select id="filter-estatus" onchange="filterLista()"
|
||||
class="px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-emerald-500 focus:border-emerald-500 shadow-sm cursor-pointer">
|
||||
<option value="">Todos los Estatus</option>
|
||||
</select>
|
||||
<span id="lista-count"
|
||||
class="text-xs text-slate-500 font-medium bg-slate-100 px-2 py-1 rounded-full"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-slate-100 text-slate-600 text-xs uppercase font-semibold">
|
||||
<tr>
|
||||
<th class="px-3 py-3 border-b text-center w-10">N°</th>
|
||||
<th class="px-3 py-3 border-b">Grado</th>
|
||||
<th class="px-3 py-3 border-b">Apellidos y Nombres</th>
|
||||
<th class="px-3 py-3 border-b">Cédula</th>
|
||||
<th class="px-3 py-3 border-b w-44">Estatus</th>
|
||||
<th class="px-3 py-3 border-b w-32">F. Salida</th>
|
||||
<th class="px-3 py-3 border-b w-32">F. Llegada</th>
|
||||
<th class="px-3 py-3 border-b text-center w-16">Orden</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lista-body" class="text-sm divide-y divide-gray-100">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================== GESTIÓN SECTION (CRUD) ==================== -->
|
||||
<section id="section-gestion" class="tab-content hidden fade-in">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-slate-800">Gestión de Personal</h1>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick="openEstatusModal()"
|
||||
class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-1 shadow"
|
||||
title="Configurar Items de Situación">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z">
|
||||
</path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
Estatus
|
||||
</button>
|
||||
<input type="file" id="excelFile" accept=".xlsx, .xls" class="hidden"
|
||||
onchange="handleExcelUpload(event)">
|
||||
<button type="button" onclick="document.getElementById('excelFile').click()"
|
||||
class="bg-blue-100 hover:bg-blue-200 text-blue-700 px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-1 shadow"
|
||||
title="Importar Masiva en Excel">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||
</svg> Importar
|
||||
</button>
|
||||
<!-- Export Dropdown / Buttons -->
|
||||
<button type="button" onclick="exportDataExcel()"
|
||||
class="bg-orange-100 hover:bg-orange-200 text-orange-700 px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-1 shadow"
|
||||
title="Exportar Lista a Excel">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
|
||||
</path>
|
||||
</svg> Excel
|
||||
</button>
|
||||
<button type="button" onclick="exportDataPDF()"
|
||||
class="bg-rose-100 hover:bg-rose-200 text-rose-700 px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-1 shadow"
|
||||
title="Exportar Lista a PDF">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
|
||||
</path>
|
||||
</svg> PDF
|
||||
</button>
|
||||
<button onclick="openCrudModal()"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg shadow-md font-bold flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4">
|
||||
</path>
|
||||
</svg>
|
||||
Registrar Nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CRUD Modal -->
|
||||
<div id="crudModal"
|
||||
class="fixed inset-0 bg-slate-900 bg-opacity-50 z-[100] hidden flex items-center justify-center p-4 fade-in">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4 border-b pb-2">
|
||||
<h2 class="text-lg font-bold text-slate-700" id="formTitle">Registrar Nuevo</h2>
|
||||
<button type="button" onclick="closeCrudModal()" class="text-gray-400 hover:text-red-500">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="crudForm">
|
||||
<input type="hidden" id="personalId">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Cédula</label>
|
||||
<input type="number" id="cedula" required pattern="\\d+"
|
||||
title="Solo se permiten números"
|
||||
oninput="this.value = this.value.replace(/[^0-9]/g, '')"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-emerald-500 focus:border-emerald-500 shadow-sm transition-all text-sm">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Apellidos y Nombres</label>
|
||||
<input type="text" id="apellidos_nombres" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-emerald-500 focus:border-emerald-500 shadow-sm transition-all text-sm uppercase">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Categoría</label>
|
||||
<select id="categoria" required onchange="updateGrados()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-emerald-500 focus:border-emerald-500 shadow-sm text-sm">
|
||||
<option value="">Seleccione...</option>
|
||||
<option value="Oficiales Generales">Oficiales Generales</option>
|
||||
<option value="Oficiales Superiores">Oficiales Superiores</option>
|
||||
<option value="Oficiales Subalternos">Oficiales Subalternos</option>
|
||||
<option value="Tropa Profesional">Tropa Profesional</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Grado</label>
|
||||
<select id="grado" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-emerald-500 focus:border-emerald-500 shadow-sm text-sm">
|
||||
<option value="">Seleccione Categoría Primero</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Año Graduación
|
||||
(Antigüedad)</label>
|
||||
<input type="number" id="antiguedad" placeholder="Ej: 1991" min="1900" max="2100"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-emerald-500 focus:border-emerald-500 shadow-sm transition-all text-sm">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="submit"
|
||||
class="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-md shadow transition-colors font-medium text-sm">Guardar</button>
|
||||
<button type="button" onclick="resetForm()"
|
||||
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-md transition-colors text-sm font-medium">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200 w-full mb-8">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-slate-100 text-slate-600 text-xs uppercase font-semibold">
|
||||
<tr>
|
||||
<th class="px-3 py-3 w-10 text-center">N°</th>
|
||||
<th class="px-3 py-3 w-16">Grado</th>
|
||||
<th class="px-3 py-3 whitespace-nowrap text-left">Apellidos y Nombres</th>
|
||||
<th class="px-3 py-3 w-24">Cédula</th>
|
||||
<th class="px-3 py-3 w-16 text-center text-xs">Año</th>
|
||||
<th class="w-full"></th>
|
||||
<th class="px-3 py-3 w-28 text-right pr-6">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="crud-body" class="text-sm divide-y divide-gray-100">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Notification Toast -->
|
||||
<div id="toast"
|
||||
class="fixed bottom-5 right-5 transform translate-y-20 opacity-0 transition-all duration-300 bg-slate-800 text-white px-6 py-3 rounded shadow-xl flex items-center gap-3 z-50">
|
||||
<svg id="toast-icon" class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span id="toast-msg" class="font-medium">Notificación</span>
|
||||
</div>
|
||||
|
||||
<!-- ==================== PDF PRINT CONTAINER (hidden on screen) ==================== -->
|
||||
<div id="pdf-container" class="hidden">
|
||||
<!-- Content is injected dynamically by JS before printing -->
|
||||
</div>
|
||||
|
||||
<!-- Modal Gestion Estatus -->
|
||||
<div id="estatusModal" class="fixed inset-0 bg-slate-900 bg-opacity-50 hidden justify-center items-center z-50">
|
||||
<div class="bg-white rounded-xl shadow-2xl p-6 w-full max-w-lg">
|
||||
<h2 class="text-xl font-bold text-slate-800 mb-4 border-b pb-2">Gestión de Estatus</h2>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input type="text" id="newEstatusName" placeholder="Ej: COMISIÓN ESPECIAL"
|
||||
class="w-full px-3 py-2 border rounded text-sm uppercase focus:ring-emerald-500">
|
||||
<button onclick="addNuevoEstatus()"
|
||||
class="bg-emerald-600 text-white px-4 py-2 rounded text-sm hover:bg-emerald-700 transition">Agregar</button>
|
||||
</div>
|
||||
<ul id="estatusListUI" class="max-h-64 overflow-y-auto divide-y divide-gray-100 pr-2">
|
||||
</ul>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button onclick="closeEstatusModal()"
|
||||
class="bg-gray-200 text-gray-800 px-4 py-2 rounded text-sm hover:bg-gray-300 transition">Regresar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
27
parte_numerico/tmp_check.py
Normal file
27
parte_numerico/tmp_check.py
Normal file
@ -0,0 +1,27 @@
|
||||
import sqlite3
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
def similar(a, b):
|
||||
return SequenceMatcher(None, a, b).ratio()
|
||||
|
||||
conn = sqlite3.connect('e:/PROYECTOS/ProyectoParte/parte_numerico/datos.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = [dict(r) for r in conn.execute('SELECT * FROM personal').fetchall()]
|
||||
|
||||
print(f"Total rows: {len(rows)}")
|
||||
|
||||
# Find similar names (>0.8 ratio)
|
||||
for i in range(len(rows)):
|
||||
for j in range(i+1, len(rows)):
|
||||
s = similar(rows[i]['apellidos_nombres'], rows[j]['apellidos_nombres'])
|
||||
if s > 0.8:
|
||||
print(f"Similar: {s:.2f} | {rows[i]['apellidos_nombres']} (ID {rows[i]['id']}) vs {rows[j]['apellidos_nombres']} (ID {rows[j]['id']})")
|
||||
|
||||
# Look for empty or weird fields
|
||||
for r in rows:
|
||||
if not r['cedula'] or len(str(r['cedula'])) < 5:
|
||||
print(f"Weird cedula: {r}")
|
||||
if not r['apellidos_nombres'] or len(str(r['apellidos_nombres'])) < 5:
|
||||
print(f"Weird name: {r}")
|
||||
if 'PRUEBA' in str(r['apellidos_nombres']).upper() or 'TEST' in str(r['apellidos_nombres']).upper():
|
||||
print(f"Test data: {r}")
|
||||
7
parte_numerico/tmp_dump.py
Normal file
7
parte_numerico/tmp_dump.py
Normal file
@ -0,0 +1,7 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('e:/PROYECTOS/ProyectoParte/parte_numerico/datos.db')
|
||||
cursor = conn.execute('SELECT id, cedula, apellidos_nombres FROM personal ORDER BY apellidos_nombres')
|
||||
with open('e:/PROYECTOS/ProyectoParte/parte_numerico/dump_names.txt', 'w', encoding='utf-8') as f:
|
||||
for row in cursor.fetchall():
|
||||
f.write(f'{row[0]}, {row[1]}, {row[2]}\n')
|
||||
36
parte_numerico/tmp_find_dups.py
Normal file
36
parte_numerico/tmp_find_dups.py
Normal file
@ -0,0 +1,36 @@
|
||||
import sqlite3
|
||||
|
||||
def normalize_name(name):
|
||||
words = [w.strip() for w in str(name).upper().split() if w.strip()]
|
||||
return ' '.join(sorted(words))
|
||||
|
||||
conn = sqlite3.connect('datos.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute('SELECT id, cedula, apellidos_nombres FROM personal')
|
||||
rows = cursor.fetchall()
|
||||
|
||||
seen = {}
|
||||
duplicates = []
|
||||
|
||||
for r in rows:
|
||||
norm = normalize_name(r['apellidos_nombres'])
|
||||
if norm in seen:
|
||||
duplicates.append((seen[norm], dict(r)))
|
||||
else:
|
||||
seen[norm] = dict(r)
|
||||
|
||||
print(f'Total records: {len(rows)}')
|
||||
if duplicates:
|
||||
print('\nFOUND DUPLICATES (by normalized name):')
|
||||
for orig, dup in duplicates:
|
||||
print(f"Original: ID={orig['id']} Cedula={orig['cedula']} Nombre={orig['apellidos_nombres']}")
|
||||
print(f"Duplicate: ID={dup['id']} Cedula={dup['cedula']} Nombre={dup['apellidos_nombres']}\n")
|
||||
|
||||
# Optional: Delete duplicates?
|
||||
# Un-comment below to delete duplicates automatically if they have the exact same cedula:
|
||||
for orig, dup in duplicates:
|
||||
print(f"Deleting duplicate ID {dup['id']} ({dup['apellidos_nombres']})...")
|
||||
conn.execute('DELETE FROM personal WHERE id = ?', (dup['id'],))
|
||||
conn.commit()
|
||||
else:
|
||||
print('No duplicates found by normalized name.')
|
||||
Loading…
x
Reference in New Issue
Block a user