feat: implement Flask backend with SQLite database and duplicate detection logic for personal management

This commit is contained in:
Arcangel 2026-08-19 11:00:35 -04:00
commit 9cc12d414f
13 changed files with 2123 additions and 0 deletions

0
app_dump.txt Normal file
View File

Binary file not shown.

337
parte_numerico/app.py Normal file
View 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

Binary file not shown.

BIN
parte_numerico/dump.txt Normal file

Binary file not shown.

View 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

View File

@ -0,0 +1 @@
Flask==3.0.0

View 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;
}

File diff suppressed because it is too large Load Diff

View 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"></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"></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>

View 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}")

View 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')

View 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.')