This commit is contained in:
Jhonathan Guevara 2025-12-16 21:52:31 -05:00
commit 02e3afa684
Signed by: jhonathan_guevara
GPG Key ID: 619239F12DCBE55B
38 changed files with 39753 additions and 0 deletions

57
.gitignore vendored Normal file
View File

@ -0,0 +1,57 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
# Node
**/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Angular build
**/dist/
**/.angular/
**/.cache/
# Env
**/.env

59
README.md Normal file
View File

@ -0,0 +1,59 @@
# Aunarsalud
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.0.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

17
aunarsalud/.editorconfig Normal file
View File

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

4
aunarsalud/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
aunarsalud/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
aunarsalud/.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

74
aunarsalud/angular.json Normal file
View File

@ -0,0 +1,74 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": "b39a5eb1-289b-4a2b-84cd-170e9d15a2a7"
},
"newProjectRoot": "projects",
"projects": {
"aunarsalud": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "aunarsalud:build:production"
},
"development": {
"buildTarget": "aunarsalud:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

9352
aunarsalud/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
aunarsalud/package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "aunarsalud",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"packageManager": "npm@10.9.3",
"dependencies": {
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"chart.js": "^4.5.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.0.0",
"@angular/cli": "^21.0.0",
"@angular/compiler-cli": "^21.0.0",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -0,0 +1,11 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes)
]
};

View File

326
aunarsalud/src/app/app.html Normal file
View File

@ -0,0 +1,326 @@
<div class="contenedor">
<h1>{{ titulo }}</h1>
<!-- ===================== -->
<!-- IMPORTACIÓN EXCEL -->
<!-- ===================== -->
<div class="seccion">
<h2>Importación de Excel (PLANILLA vs HISTOR)</h2>
<div *ngIf="errorImport" class="error">{{ errorImport }}</div>
<div class="info" *ngIf="cargandoImport">Consultando estado...</div>
<div *ngIf="estadoImport">
<p>
<strong>Estado:</strong>
Planilla:
<span [style.color]="estadoImport.planilla_cargada ? 'green' : 'red'">
{{ estadoImport.planilla_cargada ? '✅ Cargada' : '❌ No cargada' }}
</span>
|
Histor:
<span [style.color]="estadoImport.histor_cargada ? 'green' : 'red'">
{{ estadoImport.histor_cargada ? '✅ Cargado' : '❌ No cargado' }}
</span>
</p>
<p *ngIf="estadoImport.ultima_sync">
<strong>Última sync:</strong> {{ estadoImport.ultima_sync }}
</p>
</div>
<div style="display:flex; gap:16px; flex-wrap:wrap; align-items:center;">
<div>
<strong>Citas programadas (PLANILLA)</strong>
<input type="file" (change)="onSeleccionarPlanilla($event)" />
<button (click)="subirPlanilla()" [disabled]="subiendoPlanilla">
{{ subiendoPlanilla ? 'Cargando...' : 'Cargar citas programadas' }}
</button>
</div>
<div>
<strong>Citas realizadas (HISTOR)</strong>
<input type="file" (change)="onSeleccionarHistor($event)" />
<button (click)="subirHistor()" [disabled]="subiendoHistor">
{{ subiendoHistor ? 'Cargando...' : 'Cargar citas realizadas' }}
</button>
</div>
<div>
<button (click)="refrescarEstadoImport()">Refrescar estado</button>
</div>
</div>
<div *ngIf="logImport.length > 0" class="info" style="margin-top:10px; white-space:pre-wrap;">
<h4>Log importación</h4>
<div *ngFor="let l of logImport">{{ l }}</div>
</div>
</div>
<!-- ===================== -->
<!-- HISTOGRAMA GENERAL -->
<!-- ===================== -->
<div class="seccion">
<h2>Histograma general (plan anual real)</h2>
<button (click)="cargarHistogramaGeneral()" [disabled]="cargandoHistGeneral">
{{ cargandoHistGeneral ? 'Cargando...' : 'Ver histograma general' }}
</button>
<div *ngIf="errorHistGeneral" class="error">{{ errorHistGeneral }}</div>
<div *ngIf="histGeneral">
<p>
<strong>Pacientes incluidos:</strong> {{ histGeneral.pacientes_incluidos }}
|
<strong>Total esperado:</strong> {{ histGeneral.total_esperado }}
|
<strong>Total asistidas:</strong> {{ histGeneral.total_asistidas }}
|
<strong>% Cumplimiento:</strong> {{ histGeneral.porcentaje }}%
</p>
<h4>Por especialidad</h4>
<canvas #canvasGeneralEsp style="width:100%; max-width:1100px; height:360px;"></canvas>
<h4 style="margin-top:18px;">Por mes del programa (1..12)</h4>
<canvas #canvasGeneralMes style="width:100%; max-width:1100px; height:360px;"></canvas>
</div>
</div>
<!-- ===================== -->
<!-- Tipo de búsqueda -->
<!-- ===================== -->
<div class="seccion">
<h3>Tipo de búsqueda</h3>
<label>
<input type="radio" name="tipoBusqueda" value="documento" [(ngModel)]="tipoBusqueda" />
Por número de documento
</label>
<label>
<input type="radio" name="tipoBusqueda" value="nombre" [(ngModel)]="tipoBusqueda" />
Por nombre completo
</label>
</div>
<!-- Buscador -->
<div class="seccion buscador">
<input
type="text"
[(ngModel)]="termino"
[placeholder]="tipoBusqueda === 'documento'
? 'Ej: 1013105461 o 1013...'
: 'Ej: JHONATHAN, GUALDRON...'"
(keyup.enter)="buscar()"
/>
<button (click)="buscar()">Buscar</button>
</div>
<div *ngIf="cargando" class="info">Cargando pacientes...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<!-- Resultados de pacientes -->
<div class="seccion" *ngIf="pacientes.length > 0">
<h3>Pacientes encontrados</h3>
<table class="tabla">
<thead>
<tr>
<th>Tipo doc.</th>
<th>Documento</th>
<th>Nombre completo</th>
<th>Género</th>
<th>Edad</th>
<th>Detalle</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let p of pacientes">
<td>{{ p.tipo_documento }}</td>
<td>{{ p.numero_documento }}</td>
<td>{{ p.nombre_completo }}</td>
<td>{{ p.genero }}</td>
<td>{{ p.edad }}</td>
<td>
<button (click)="seleccionarPaciente(p)">Ver citas</button>
</td>
</tr>
</tbody>
</table>
</div>
<p *ngIf="!cargando && pacientes.length === 0 && !error" class="info">
No hay resultados. Ingresa un criterio de búsqueda y presiona "Buscar".
</p>
<!-- Detalle de paciente -->
<div class="seccion detalle" *ngIf="pacienteSeleccionado">
<h3>Detalle del paciente</h3>
<p><strong>Documento:</strong> {{ pacienteSeleccionado.numero_documento }}</p>
<p><strong>Nombre:</strong> {{ pacienteSeleccionado.nombre_completo }}</p>
<p><strong>Género:</strong> {{ pacienteSeleccionado.genero }}</p>
<p><strong>Edad:</strong> {{ pacienteSeleccionado.edad }}</p>
<p>
<strong>Fecha de nacimiento:</strong>
{{ pacienteSeleccionado.fecha_nacimiento | date:'yyyy-MM-dd' }}
</p>
<p><strong>Celular:</strong> {{ pacienteSeleccionado.celular }}</p>
<p><strong>Correo:</strong> {{ pacienteSeleccionado.correo }}</p>
<!-- HISTOGRAMA PACIENTE -->
<h4>Histograma anual del paciente (plan por meses)</h4>
<div *ngIf="cargandoHistPaciente" class="info">Cargando histograma del paciente...</div>
<div *ngIf="errorHistPaciente" class="error">{{ errorHistPaciente }}</div>
<div *ngIf="histPaciente">
<p>
<strong>Ingreso:</strong> {{ histPaciente.ingreso.fecha }} ({{ histPaciente.ingreso.fuente }})
|
<strong>Mes del programa:</strong> {{ histPaciente.ventana.mes_programa }}
</p>
<p>
<strong>Este mes:</strong>
{{ histPaciente.cumplimiento.asistidas_mes_actual }}/{{ histPaciente.cumplimiento.esperado_mes_actual }}
({{ histPaciente.cumplimiento.porcentaje_mes_actual }}%)
|
<strong>Hasta hoy:</strong>
{{ histPaciente.cumplimiento.asistidas_hasta_hoy }}/{{ histPaciente.cumplimiento.esperado_hasta_hoy }}
({{ histPaciente.cumplimiento.porcentaje_hasta_hoy }}%)
</p>
<canvas #canvasPaciente style="width:100%; max-width:1100px; height:360px;"></canvas>
<table class="tabla" style="margin-top:10px;">
<thead>
<tr>
<th>Mes</th>
<th>Rango</th>
<th>Esperado</th>
<th>Asistidas</th>
<th>Pendientes</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let m of histPaciente.porMes">
<td>{{ m.mes }}</td>
<td>{{ m.inicio }} → {{ m.fin }}</td>
<td>{{ m.esperado }}</td>
<td>{{ m.asistidas }}</td>
<td>{{ m.pendientes }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Citas existentes -->
<h4>Citas</h4>
<div *ngIf="cargandoCitas" class="info">Cargando citas...</div>
<table *ngIf="!cargandoCitas && citas.length > 0" class="tabla">
<thead>
<tr>
<th>Fecha</th>
<th>Hora</th>
<th>Especialidad</th>
<th>Tipo</th>
<th>Modalidad</th>
<th>Asistió</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let c of citas; let i = index">
<td>{{ c.fecha_cita | date:'yyyy-MM-dd' }}</td>
<td>{{ c.hora_cita || '-' }}</td>
<td>{{ c.especialidad }}</td>
<td>{{ c.tipo_cita || '-' }}</td>
<td>{{ c.modalidad || '-' }}</td>
<td>
<ng-container *ngIf="i < 2 && c.asistio !== true; else asistioFijo">
<input
type="checkbox"
[ngModel]="false"
(ngModelChange)="onToggleAsistencia(c, $event)"
[disabled]="guardandoAsistenciaId === c.id_cita"
/>
</ng-container>
<ng-template #asistioFijo>
{{ c.asistio ? 'Sí' : 'No' }}
</ng-template>
</td>
<td>{{ c.observaciones || '-' }}</td>
</tr>
</tbody>
</table>
<p *ngIf="errorAsistencia" class="error">{{ errorAsistencia }}</p>
<p *ngIf="!cargandoCitas && citas.length === 0" class="info">
Este paciente no tiene citas registradas.
</p>
<!-- Formulario para agendar nueva cita -->
<div class="seccion-agendar">
<h4>Agendar nueva cita</h4>
<div *ngIf="errorCita" class="error">{{ errorCita }}</div>
<div *ngIf="mensajeCita" class="ok">{{ mensajeCita }}</div>
<div class="form-grid">
<div class="campo">
<label>Especialidad</label>
<select [(ngModel)]="formCita.id_especialidad">
<option value="">-- Selecciona --</option>
<option *ngFor="let e of especialidades" [value]="e.id_especialidad">
{{ e.nombre }}
</option>
</select>
</div>
<div class="campo">
<label>Fecha de cita</label>
<input type="date" [(ngModel)]="formCita.fecha_cita" />
</div>
<div class="campo">
<label>Tipo de cita</label>
<input
type="text"
[(ngModel)]="formCita.tipo_cita"
placeholder="Primera vez, Control..."
/>
</div>
<div class="campo">
<label>Hora de cita</label>
<input type="time" [(ngModel)]="formCita.hora_cita" />
</div>
<div class="campo">
<label>Modalidad</label>
<select [(ngModel)]="formCita.modalidad">
<option value="">-- Selecciona --</option>
<option value="PRESENCIAL">Presencial</option>
<option value="TELEMEDICINA">Telemedicina</option>
</select>
</div>
<div class="campo campo-full">
<label>Observaciones</label>
<textarea
rows="3"
[(ngModel)]="formCita.observaciones"
placeholder="Notas sobre la cita"
></textarea>
</div>
</div>
<button (click)="guardarCita()" [disabled]="guardandoCita">
{{ guardandoCita ? 'Guardando...' : 'Guardar cita' }}
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,3 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];

View File

@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, aunarsalud');
});
});

521
aunarsalud/src/app/app.ts Normal file
View File

@ -0,0 +1,521 @@
import {
Component,
OnInit,
ChangeDetectorRef,
ViewChild,
ElementRef
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { finalize } from 'rxjs/operators';
import Chart from 'chart.js/auto';
import {
PacienteService,
Paciente,
Cita,
Especialidad,
NuevaCita
} from './servicios/paciente';
import {
ImportacionService,
EstadoImportacion,
RespuestaImportacion,
HistogramaPaciente,
HistogramaGeneral
} from './servicios/importacion';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './app.html',
styleUrls: ['./app.css'],
})
export class AppComponent implements OnInit {
titulo = 'AUNAR Salud';
tipoBusqueda: 'documento' | 'nombre' = 'documento';
termino = '';
cargando = false;
error: string | null = null;
pacientes: Paciente[] = [];
pacienteSeleccionado: Paciente | null = null;
citas: Cita[] = [];
cargandoCitas = false;
especialidades: Especialidad[] = [];
// formulario de nueva cita
formCita = {
id_especialidad: '',
fecha_cita: '',
hora_cita: '',
tipo_cita: '',
observaciones: '',
modalidad: ''
};
guardandoCita = false;
mensajeCita: string | null = null;
errorCita: string | null = null;
// edición asistencia
guardandoAsistenciaId: number | null = null;
errorAsistencia: string | null = null;
// --- IMPORTACIÓN EXCEL ---
estadoImport: EstadoImportacion | null = null;
cargandoImport = false;
subiendoPlanilla = false;
subiendoHistor = false;
logImport: string[] = [];
errorImport: string | null = null;
archivoPlanilla: File | null = null;
archivoHistor: File | null = null;
// --- HISTOGRAMAS ---
histPaciente: HistogramaPaciente | null = null;
cargandoHistPaciente = false;
errorHistPaciente: string | null = null;
histGeneral: HistogramaGeneral | null = null;
cargandoHistGeneral = false;
errorHistGeneral: string | null = null;
// --- CANVAS CHARTS ---
@ViewChild('canvasGeneralEsp') canvasGeneralEsp!: ElementRef<HTMLCanvasElement>;
@ViewChild('canvasGeneralMes') canvasGeneralMes!: ElementRef<HTMLCanvasElement>;
@ViewChild('canvasPaciente') canvasPaciente!: ElementRef<HTMLCanvasElement>;
private grafGeneralEsp: Chart | null = null;
private grafGeneralMes: Chart | null = null;
private grafPaciente: Chart | null = null;
constructor(
private pacienteService: PacienteService,
private importacionService: ImportacionService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
this.cargando = true;
this.error = null;
// Pacientes iniciales
this.pacienteService
.listarPacientes()
.pipe(finalize(() => {
this.cargando = false;
this.cdr.markForCheck();
}))
.subscribe({
next: (datos: Paciente[]) => this.pacientes = datos,
error: () => this.error = 'Error al cargar pacientes iniciales.'
});
// Especialidades
this.pacienteService
.obtenerEspecialidades()
.pipe(finalize(() => this.cdr.markForCheck()))
.subscribe({
next: (datos: Especialidad[]) => this.especialidades = datos,
error: () => {}
});
// Estado import + hist general
this.refrescarEstadoImport();
this.cargarHistogramaGeneral();
}
// =========================
// IMPORTACIÓN EXCEL
// =========================
refrescarEstadoImport(): void {
this.cargandoImport = true;
this.errorImport = null;
this.importacionService
.estado()
.pipe(finalize(() => {
this.cargandoImport = false;
this.cdr.markForCheck();
}))
.subscribe({
next: (e) => this.estadoImport = e,
error: () => this.errorImport = 'No se pudo consultar el estado de importación.'
});
}
onSeleccionarPlanilla(ev: Event): void {
const input = ev.target as HTMLInputElement;
this.archivoPlanilla = input.files && input.files.length ? input.files[0] : null;
}
onSeleccionarHistor(ev: Event): void {
const input = ev.target as HTMLInputElement;
this.archivoHistor = input.files && input.files.length ? input.files[0] : null;
}
subirPlanilla(): void {
if (!this.archivoPlanilla) {
this.errorImport = 'Selecciona un archivo de PLANILLA primero.';
return;
}
this.subiendoPlanilla = true;
this.errorImport = null;
this.logImport = [];
this.importacionService
.subirPlanilla(this.archivoPlanilla)
.pipe(finalize(() => {
this.subiendoPlanilla = false;
this.cdr.markForCheck();
}))
.subscribe({
next: (r: RespuestaImportacion) => {
this.logImport.push(r.mensaje || '✅ Planilla cargada.');
if (r.stdout) this.logImport.push(r.stdout);
if (r.stderr) this.logImport.push('⚠️ ' + r.stderr);
this.refrescarEstadoImport();
this.cargarHistogramaGeneral();
if (this.pacienteSeleccionado) {
this.cargarCitas(this.pacienteSeleccionado.numero_documento);
this.cargarHistogramaPaciente(this.pacienteSeleccionado.numero_documento);
}
},
error: (err: any) => {
this.errorImport = err?.error?.detalle || 'Error subiendo planilla.';
}
});
}
subirHistor(): void {
if (!this.archivoHistor) {
this.errorImport = 'Selecciona un archivo de HISTOR primero.';
return;
}
this.subiendoHistor = true;
this.errorImport = null;
this.logImport = [];
this.importacionService
.subirHistor(this.archivoHistor)
.pipe(finalize(() => {
this.subiendoHistor = false;
this.cdr.markForCheck();
}))
.subscribe({
next: (r: RespuestaImportacion) => {
this.logImport.push(r.mensaje || '✅ Histor cargado.');
if (r.stdout) this.logImport.push(r.stdout);
if (r.stderr) this.logImport.push('⚠️ ' + r.stderr);
this.refrescarEstadoImport();
this.cargarHistogramaGeneral();
if (this.pacienteSeleccionado) {
this.cargarCitas(this.pacienteSeleccionado.numero_documento);
this.cargarHistogramaPaciente(this.pacienteSeleccionado.numero_documento);
}
},
error: (err: any) => {
this.errorImport = err?.error?.detalle || 'Error subiendo histor.';
}
});
}
// =========================
// HISTOGRAMA GENERAL + GRAFICOS
// =========================
cargarHistogramaGeneral(): void {
this.cargandoHistGeneral = true;
this.errorHistGeneral = null;
this.importacionService
.histogramaGeneral()
.pipe(finalize(() => {
this.cargandoHistGeneral = false;
this.cdr.markForCheck();
}))
.subscribe({
next: (h) => {
this.histGeneral = h;
this.cdr.detectChanges();
setTimeout(() => {
this.pintarGraficoGeneralEspecialidad();
this.pintarGraficoGeneralMes();
}, 0);
},
error: () => this.errorHistGeneral = 'No se pudo cargar histograma general.'
});
}
private pintarGraficoGeneralEspecialidad(): void {
if (!this.histGeneral?.porEspecialidad || !this.canvasGeneralEsp) return;
const labels = this.histGeneral.porEspecialidad.map(x => x.especialidad);
const esperado = this.histGeneral.porEspecialidad.map(x => x.esperado);
const asistidas = this.histGeneral.porEspecialidad.map(x => x.asistidas);
if (this.grafGeneralEsp) this.grafGeneralEsp.destroy();
this.grafGeneralEsp = new Chart(this.canvasGeneralEsp.nativeElement, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Esperado', data: esperado },
{ label: 'Asistidas', data: asistidas }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } },
scales: { y: { beginAtZero: true } }
}
});
}
private pintarGraficoGeneralMes(): void {
if (!this.histGeneral?.porMesPrograma || !this.canvasGeneralMes) return;
const labels = this.histGeneral.porMesPrograma.map(x => `Mes ${x.mes}`);
const esperado = this.histGeneral.porMesPrograma.map(x => x.esperado);
const asistidas = this.histGeneral.porMesPrograma.map(x => x.asistidas);
if (this.grafGeneralMes) this.grafGeneralMes.destroy();
this.grafGeneralMes = new Chart(this.canvasGeneralMes.nativeElement, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Esperado', data: esperado },
{ label: 'Asistidas', data: asistidas }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } },
scales: { y: { beginAtZero: true } }
}
});
}
// =========================
// HISTOGRAMA PACIENTE + GRAFICO
// =========================
cargarHistogramaPaciente(numeroDocumento: string): void {
this.cargandoHistPaciente = true;
this.errorHistPaciente = null;
this.histPaciente = null;
this.importacionService
.histogramaPaciente(numeroDocumento)
.pipe(finalize(() => {
this.cargandoHistPaciente = false;
this.cdr.markForCheck();
}))
.subscribe({
next: (h) => {
this.histPaciente = h;
this.cdr.detectChanges();
setTimeout(() => this.pintarGraficoPaciente(), 0);
},
error: () => {
this.errorHistPaciente = 'No se pudo cargar el histograma del paciente.';
}
});
}
private pintarGraficoPaciente(): void {
if (!this.histPaciente?.porMes || !this.canvasPaciente) return;
const labels = this.histPaciente.porMes.map(x => `Mes ${x.mes}`);
const esperado = this.histPaciente.porMes.map(x => x.esperado);
const asistidas = this.histPaciente.porMes.map(x => x.asistidas);
if (this.grafPaciente) this.grafPaciente.destroy();
this.grafPaciente = new Chart(this.canvasPaciente.nativeElement, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Esperado', data: esperado },
{ label: 'Asistidas', data: asistidas }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'top' } },
scales: { y: { beginAtZero: true } }
}
});
}
// =========================
// BÚSQUEDA PACIENTES
// =========================
buscar(): void {
this.error = null;
this.cargando = true;
this.pacientes = [];
this.pacienteSeleccionado = null;
this.citas = [];
this.mensajeCita = null;
this.errorCita = null;
this.errorAsistencia = null;
this.histPaciente = null;
const valor = this.termino.trim();
if (!valor) {
this.cargando = false;
this.error = 'Ingresa un valor para buscar.';
return;
}
const obs$ = (this.tipoBusqueda === 'documento')
? this.pacienteService.buscarPorDocumento(valor)
: this.pacienteService.buscarPorNombre(valor);
obs$
.pipe(finalize(() => {
this.cargando = false;
this.cdr.markForCheck();
}))
.subscribe({
next: (datos: Paciente[]) => this.pacientes = datos,
error: () => this.error = 'Error al consultar pacientes.'
});
}
seleccionarPaciente(paciente: Paciente): void {
this.pacienteSeleccionado = paciente;
this.mensajeCita = null;
this.errorCita = null;
this.errorAsistencia = null;
this.formCita = {
id_especialidad: '',
fecha_cita: '',
hora_cita: '',
tipo_cita: '',
observaciones: '',
modalidad: ''
};
this.cargarCitas(paciente.numero_documento);
this.cargarHistogramaPaciente(paciente.numero_documento);
}
cargarCitas(numeroDocumento: string): void {
this.cargandoCitas = true;
this.citas = [];
this.pacienteService
.obtenerCitasPaciente(numeroDocumento)
.pipe(finalize(() => {
this.cargandoCitas = false;
this.cdr.markForCheck();
}))
.subscribe({
next: (datos: Cita[]) => this.citas = datos,
error: () => {}
});
}
// =========================
// CREAR CITA
// =========================
guardarCita(): void {
if (!this.pacienteSeleccionado) return;
this.errorCita = null;
this.mensajeCita = null;
if (!this.formCita.id_especialidad) return void (this.errorCita = 'Debes seleccionar una especialidad.');
if (!this.formCita.fecha_cita) return void (this.errorCita = 'Debes seleccionar una fecha de cita.');
if (!this.formCita.modalidad) return void (this.errorCita = 'Debes seleccionar si la cita es presencial o telemedicina.');
if (!this.formCita.hora_cita) return void (this.errorCita = 'Debes seleccionar una hora de cita.');
const payload: NuevaCita = {
numero_documento: this.pacienteSeleccionado.numero_documento,
id_especialidad: Number(this.formCita.id_especialidad),
fecha_cita: this.formCita.fecha_cita,
hora_cita: this.formCita.hora_cita,
modalidad: this.formCita.modalidad,
tipo_cita: this.formCita.tipo_cita || null,
observaciones: this.formCita.observaciones || null
};
this.guardandoCita = true;
this.pacienteService
.crearCita(payload)
.pipe(finalize(() => {
this.guardandoCita = false;
this.cdr.markForCheck();
}))
.subscribe({
next: (nueva: Cita) => {
this.citas.unshift(nueva);
this.mensajeCita = 'Cita creada correctamente.';
this.formCita.tipo_cita = '';
this.formCita.observaciones = '';
// refrescar histogramas
if (this.pacienteSeleccionado) {
this.cargarHistogramaPaciente(this.pacienteSeleccionado.numero_documento);
}
this.cargarHistogramaGeneral();
},
error: (err: any) => {
this.errorCita = err?.error?.error || 'Error creando la cita. Revisa los datos.';
}
});
}
onToggleAsistencia(cita: Cita, nuevoValor: boolean): void {
this.errorAsistencia = null;
const valorAnterior = cita.asistio === true;
cita.asistio = nuevoValor;
this.guardandoAsistenciaId = cita.id_cita;
this.pacienteService
.actualizarAsistenciaCita(cita.id_cita, nuevoValor)
.pipe(finalize(() => {
this.guardandoAsistenciaId = null;
this.cdr.markForCheck();
}))
.subscribe({
next: (actualizada: Cita) => {
Object.assign(cita, actualizada);
if (this.pacienteSeleccionado) {
this.cargarHistogramaPaciente(this.pacienteSeleccionado.numero_documento);
}
this.cargarHistogramaGeneral();
},
error: (err: any) => {
this.errorAsistencia =
err?.error?.error ||
'No se pudo actualizar la asistencia (solo se permiten cambios en las dos últimas citas).';
cita.asistio = valorAnterior;
}
});
}
}

View File

@ -0,0 +1,9 @@
export interface Cita {
id_cita: number;
numero_documento: string;
especialidad: string;
fecha_cita: string; // 'YYYY-MM-DD'
tipo_cita: string | null;
asistio: boolean;
observaciones: string | null;
}

View File

@ -0,0 +1,7 @@
export interface Paciente {
numero_documento: string;
nombre_completo: string;
genero: string | null;
edad: number | null;
fecha_nacimiento: string; // 'YYYY-MM-DD'
}

View File

@ -0,0 +1,98 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface EstadoImportacion {
planilla_cargada: boolean;
histor_cargada: boolean;
planilla_nombre: string | null;
histor_nombre: string | null;
ultima_sync: string | null;
}
export interface RespuestaImportacion {
ok: boolean;
mensaje: string;
stdout?: string;
stderr?: string;
detalle?: any;
}
export interface HistGeneralEspecialidad {
especialidad: string;
esperado: number;
asistidas: number;
pendientes: number;
porcentaje: number;
}
export interface HistGeneralMesPrograma {
mes: number;
esperado: number;
asistidas: number;
}
export interface HistogramaGeneral {
pacientes_incluidos: number;
rango: { inicio: string | null; hoy: string };
total_esperado: number;
total_asistidas: number;
porcentaje: number;
porEspecialidad: HistGeneralEspecialidad[];
porMesPrograma: HistGeneralMesPrograma[];
}
export interface HistPacienteMes {
mes: number;
inicio: string;
fin: string;
esperado: number;
asistidas: number;
pendientes: number;
}
export interface HistogramaPaciente {
numero_documento: string;
ingreso: { fecha: string; fuente: string };
ventana: { inicio: string; fin: string; mes_programa: number };
cumplimiento: {
esperado_mes_actual: number;
asistidas_mes_actual: number;
porcentaje_mes_actual: number;
esperado_hasta_hoy: number;
asistidas_hasta_hoy: number;
porcentaje_hasta_hoy: number;
};
porMes: HistPacienteMes[];
}
@Injectable({ providedIn: 'root' })
export class ImportacionService {
private baseUrl = 'http://localhost:2800';
constructor(private http: HttpClient) {}
estado(): Observable<EstadoImportacion> {
return this.http.get<EstadoImportacion>(`${this.baseUrl}/api/importacion/estado`);
}
subirPlanilla(file: File): Observable<RespuestaImportacion> {
const form = new FormData();
form.append('file', file);
return this.http.post<RespuestaImportacion>(`${this.baseUrl}/api/importacion/planilla`, form);
}
subirHistor(file: File): Observable<RespuestaImportacion> {
const form = new FormData();
form.append('file', file);
return this.http.post<RespuestaImportacion>(`${this.baseUrl}/api/importacion/histor`, form);
}
histogramaGeneral(): Observable<HistogramaGeneral> {
return this.http.get<HistogramaGeneral>(`${this.baseUrl}/api/histogramas/general`);
}
histogramaPaciente(numeroDocumento: string): Observable<HistogramaPaciente> {
return this.http.get<HistogramaPaciente>(`${this.baseUrl}/api/histogramas/paciente/${numeroDocumento}`);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { Paciente } from './paciente';
describe('Paciente', () => {
let service: Paciente;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(Paciente);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,92 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
// ==== MODELOS ====
export interface Paciente {
tipo_documento: string;
numero_documento: string;
nombre_completo: string;
genero: string | null;
edad: number | null;
fecha_nacimiento: string; // 'YYYY-MM-DD'
celular?: string | null;
correo?: string | null;
}
export interface Cita {
id_cita: number;
numero_documento: string;
especialidad: string;
fecha_cita: string; // 'YYYY-MM-DD'
hora_cita: string | null; // 'HH:mm:ss' o null
tipo_cita: string | null;
modalidad: string | null; // PRESENCIAL / TELEMEDICINA / null
asistio: boolean | null;
observaciones: string | null;
}
export interface Especialidad {
id_especialidad: number;
nombre: string;
}
export interface NuevaCita {
numero_documento: string;
id_especialidad: number;
fecha_cita: string; // 'YYYY-MM-DD'
hora_cita: string; // 'HH:mm'
modalidad: string; // PRESENCIAL / TELEMEDICINA
tipo_cita?: string | null;
observaciones?: string | null;
}
// ==== SERVICIO ====
@Injectable({
providedIn: 'root'
})
export class PacienteService {
private apiUrl = 'http://localhost:2800/api';
constructor(private http: HttpClient) {}
listarPacientes(): Observable<Paciente[]> {
return this.http.get<Paciente[]>(`${this.apiUrl}/pacientes`);
}
buscarPorDocumento(doc: string): Observable<Paciente[]> {
const params = new HttpParams()
.set('tipo', 'documento')
.set('termino', doc.trim());
return this.http.get<Paciente[]>(`${this.apiUrl}/pacientes`, { params });
}
buscarPorNombre(nombre: string): Observable<Paciente[]> {
const params = new HttpParams()
.set('tipo', 'nombre')
.set('termino', nombre.trim());
return this.http.get<Paciente[]>(`${this.apiUrl}/pacientes`, { params });
}
obtenerCitasPaciente(numeroDocumento: string): Observable<Cita[]> {
return this.http.get<Cita[]>(`${this.apiUrl}/pacientes/${numeroDocumento}/citas`);
}
obtenerEspecialidades(): Observable<Especialidad[]> {
return this.http.get<Especialidad[]>(`${this.apiUrl}/especialidades`);
}
crearCita(datos: NuevaCita): Observable<Cita> {
return this.http.post<Cita>(`${this.apiUrl}/citas`, datos);
}
actualizarAsistenciaCita(id_cita: number, asistio: boolean): Observable<Cita> {
return this.http.patch<Cita>(
`${this.apiUrl}/citas/${id_cita}/asistencia`,
{ asistio }
);
}
}

13
aunarsalud/src/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Aunarsalud</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
aunarsalud/src/main.ts Normal file
View File

@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

110
aunarsalud/src/styles.css Normal file
View File

@ -0,0 +1,110 @@
.contenedor {
max-width: 1000px;
margin: 20px auto;
padding: 16px;
font-family: Arial, Helvetica, sans-serif;
}
h1 {
margin-bottom: 16px;
text-align: center;
}
.seccion {
margin-bottom: 20px;
}
.buscador {
display: flex;
gap: 8px;
}
.buscador input {
flex: 1;
padding: 8px;
}
.buscador button {
padding: 8px 16px;
cursor: pointer;
}
.tabla {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
.tabla th,
.tabla td {
border: 1px solid #ccc;
padding: 6px 8px;
font-size: 14px;
}
.tabla th {
background-color: #f2f2f2;
}
.detalle {
border-top: 1px solid #ddd;
padding-top: 12px;
}
.info {
margin-top: 8px;
color: #555;
}
.error {
margin-top: 8px;
color: #b00020;
font-weight: bold;
}
.seccion-agendar {
margin-top: 24px;
padding-top: 12px;
border-top: 1px solid #ddd;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.campo {
display: flex;
flex-direction: column;
gap: 4px;
}
.campo-full {
grid-column: 1 / -1;
}
.campo label {
font-size: 13px;
font-weight: 600;
}
.campo input,
.campo select,
.campo textarea {
padding: 6px;
font-size: 14px;
}
.campo-check {
display: flex;
align-items: center;
gap: 6px;
}
.ok {
margin-top: 8px;
color: #0b8043;
font-weight: bold;
}

View File

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

33
aunarsalud/tsconfig.json Normal file
View File

@ -0,0 +1,33 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}

1923
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
backend/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "aunar-backend",
"version": "1.0.0",
"main": "src/server.js",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.22.0",
"multer": "^2.0.2",
"pg": "^8.16.3",
"twilio": "^5.10.6"
},
"devDependencies": {
"nodemon": "^3.1.11"
},
"description": "",
"keywords": [],
"author": "",
"license": "ISC"
}

Binary file not shown.

22
backend/src/db.js Normal file
View File

@ -0,0 +1,22 @@
// src/db.js
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
host: process.env.PGHOST,
port: process.env.PGPORT,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
database: process.env.PGDATABASE
});
// Función helper por si quieres usar pool.query directo
async function query(text, params) {
const res = await pool.query(text, params);
return res;
}
module.exports = {
pool,
query
};

View File

@ -0,0 +1,636 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import os
import re
from typing import Dict, Optional, List
import pandas as pd
# ---- .env (backend/.env) ----
try:
from dotenv import load_dotenv
load_dotenv()
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) # backend/.env
except Exception:
pass
# ---- psycopg2 ----
try:
import psycopg2
import psycopg2.extras
except ImportError:
psycopg2 = None
# -----------------------------
# 1) Mapeos
# -----------------------------
MAP_EXAMEN_PLANILLA = {
"CONSULTA MEDICO EXPERTO": "MEDICO EXPERTO",
"TELECONSULTA MEDICO EXPERTO": "MEDICO EXPERTO",
"CONSULTA DE INFECTOLOGIA": "INFECTOLOGIA",
"CONSULTA DE PSICOLOGIA": "PSICOLOGIA",
"CONSULTA DE PSIQUIATRIA": "PSIQUIATRIA",
"CONSULTA DE PSIQUIATRÍA": "PSIQUIATRIA",
"CONSULTA CON ENFERMERIA": "ENFERMERIA",
"CONSULTA CON ENFERMERÍA": "ENFERMERIA",
"CONSULTA QUIMICO FARMACEUTICO": "QUIMICO FARMACEUTICO",
"CONSULTA QUÍMICO FARMACÉUTICO": "QUIMICO FARMACEUTICO",
"CONSULTA CON TRABAJO SOCIAL": "TRABAJO SOCIAL",
"CONSULTA DE NUTRICION": "NUTRICION",
"CONSULTA DE NUTRICIÓN": "NUTRICION",
"CONSULTA ODONTOLOGICA": "ODONTOLOGIA",
"CONSULTA ODONTOLÓGICA": "ODONTOLOGIA",
"CONSULTA DE OFTALMOLOGIA": "OFTALMOLOGIA",
"CONSULTA DE OFTALMOLOGÍA": "OFTALMOLOGIA",
}
MAP_ESPECIALIDAD_HISTOR = {
"Medico Experto": "MEDICO EXPERTO",
"Psicologia": "PSICOLOGIA",
"Psiquiatria": "PSIQUIATRIA",
"Infectologia": "INFECTOLOGIA",
"Nutricion": "NUTRICION",
"Trabajo Social": "TRABAJO SOCIAL",
"Quimico Farmaceutico": "QUIMICO FARMACEUTICO",
"Odontologo": "ODONTOLOGIA",
"Jefe de Enfermeria": "ENFERMERIA",
"Oftalmologia": "OFTALMOLOGIA",
}
def _norm(s: str) -> str:
if s is None:
return ""
s = str(s).strip()
s = re.sub(r"\s+", " ", s)
s = s.upper()
rep = str.maketrans("ÁÉÍÓÚÜÑ", "AEIOUUN")
return s.translate(rep)
def map_especialidad_planilla(examen: str) -> Optional[str]:
k = _norm(examen)
if k in MAP_EXAMEN_PLANILLA:
return MAP_EXAMEN_PLANILLA[k]
for kk, vv in MAP_EXAMEN_PLANILLA.items():
if kk in k:
return vv
return None
def map_modalidad_planilla(examen: str) -> str:
k = _norm(examen)
return "TELEMEDICINA" if "TELE" in k else "PRESENCIAL"
def map_especialidad_histor(esp: str) -> Optional[str]:
if esp in MAP_ESPECIALIDAD_HISTOR:
return MAP_ESPECIALIDAD_HISTOR[esp]
kn = _norm(esp)
for k, v in MAP_ESPECIALIDAD_HISTOR.items():
if _norm(k) == kn:
return v
return None
# -----------------------------
# 2) Helpers Excel
# -----------------------------
def _buscar_col(df: pd.DataFrame, nombre: str) -> Optional[str]:
cols = list(df.columns)
limpias = {c: str(c).strip() for c in cols}
for c, cl in limpias.items():
if cl == nombre:
return c
for c, cl in limpias.items():
if cl.startswith(nombre):
return c
return None
def _limpiar_doc(v) -> str:
if pd.isna(v):
return ""
return str(v).split(".")[0].strip()
def _limpiar_texto(v) -> str:
if pd.isna(v):
return ""
s = str(v).strip()
return "" if s.lower() in ("nan", "none") else s
def _primero_no_vacio(serie: pd.Series) -> str:
for x in serie.tolist():
s = _limpiar_texto(x)
if s:
return s
return ""
def _a_int_o_none(v) -> Optional[int]:
s = _limpiar_texto(v)
if not s:
return None
s = s.split(".")[0]
return int(s) if s.isdigit() else None
# -----------------------------
# 3) Cargar Excels
# -----------------------------
def cargar_planilla(path: str, sheets: Optional[List[str]] = None) -> pd.DataFrame:
xls = pd.ExcelFile(path)
usar = sheets or xls.sheet_names
frames = []
for sh in usar:
df = pd.read_excel(path, sheet_name=sh)
col_doc = _buscar_col(df, "Documento")
col_fecha = _buscar_col(df, "Fecha Cita")
if not col_doc or not col_fecha:
continue
df["__sheet"] = sh
frames.append(df)
if not frames:
raise RuntimeError("No se encontraron hojas válidas en PLANILLA (Documento y Fecha Cita).")
df = pd.concat(frames, ignore_index=True).copy()
col_sede = _buscar_col(df, "Sede")
col_prof = _buscar_col(df, "Nombre Profesional")
col_examen = _buscar_col(df, "Examen")
col_fecha = _buscar_col(df, "Fecha Cita")
col_estado = _buscar_col(df, "Estado")
col_doc = _buscar_col(df, "Documento")
col_pac = _buscar_col(df, "Paciente")
col_cel = _buscar_col(df, "Celular")
col_cor = _buscar_col(df, "Correo")
col_tdoc = _buscar_col(df, "T. Doc") or _buscar_col(df, "TIPO") or _buscar_col(df, "Tipo")
col_nplan = (
_buscar_col(df, "Numero de planilla")
or _buscar_col(df, "Número de planilla")
or _buscar_col(df, "numero_planilla")
or _buscar_col(df, "numero_plantilla")
or _buscar_col(df, "numero_palntilla")
)
df["Documento"] = df[col_doc].apply(_limpiar_doc)
df["Fecha Cita"] = pd.to_datetime(df[col_fecha], errors="coerce", dayfirst=True)
vacias = int(df["Fecha Cita"].isna().sum())
if vacias:
print(f"⚠️ PLANILLA: {vacias} filas sin Fecha Cita (o inválidas). Se omiten.")
df = df[df["Fecha Cita"].notna()].copy()
df["fecha_cita"] = df["Fecha Cita"].dt.date
df["hora_cita"] = df["Fecha Cita"].dt.floor("min").dt.time
df["Examen"] = df[col_examen].astype(str) if col_examen else ""
df["especialidad_db"] = df["Examen"].apply(map_especialidad_planilla)
df["modalidad_db"] = df["Examen"].apply(map_modalidad_planilla)
df["Sede"] = df[col_sede].astype(str).fillna("").str.strip() if col_sede else ""
df["Nombre Profesional"] = df[col_prof].astype(str).fillna("").str.strip() if col_prof else ""
df["Estado"] = df[col_estado].astype(str).fillna("").str.strip() if col_estado else ""
df["Paciente"] = df[col_pac].astype(str).fillna("").str.strip() if col_pac else ""
df["Celular"] = df[col_cel].astype(str).fillna("").str.strip() if col_cel else ""
df["Correo"] = df[col_cor].astype(str).fillna("").str.strip() if col_cor else ""
df["tipo_documento"] = df[col_tdoc].astype(str).fillna("").str.strip() if col_tdoc else ""
# lo guardamos como numero_plantilla en el DF (independiente de cómo venga en Excel)
if col_nplan:
df["numero_plantilla"] = df[col_nplan].apply(_a_int_o_none)
else:
df["numero_plantilla"] = None
df = df[(df["Documento"] != "") & df["especialidad_db"].notna()].copy()
return df
def cargar_histor(path: str, sheet: Optional[str] = None) -> pd.DataFrame:
sh = sheet or pd.ExcelFile(path).sheet_names[0]
df = pd.read_excel(path, sheet_name=sh)
if "Numero" not in df.columns or "Fecha Actividad" not in df.columns or "Especialidad" not in df.columns:
raise RuntimeError("HISTOR debe tener: Numero, Fecha Actividad, Especialidad.")
df["Numero"] = df["Numero"].apply(_limpiar_doc)
col_id = _buscar_col(df, "Identificación") or _buscar_col(df, "Identificacion")
if col_id:
df["tipo_documento"] = df[col_id].apply(_limpiar_texto)
df["tipo_documento"] = df["tipo_documento"].apply(
lambda s: s if re.fullmatch(r"[A-Za-z]{1,5}", s) else ""
)
else:
df["tipo_documento"] = ""
df["especialidad_db"] = df["Especialidad"].apply(map_especialidad_histor)
df["Fecha Actividad"] = pd.to_datetime(df["Fecha Actividad"], errors="coerce", dayfirst=True)
vacias = int(df["Fecha Actividad"].isna().sum())
if vacias:
print(f"⚠️ HISTOR: {vacias} filas sin Fecha Actividad (o inválidas). Se omiten.")
df = df[df["Fecha Actividad"].notna()].copy()
df["fecha_cita"] = df["Fecha Actividad"].dt.date
df["hora_cita"] = df["Fecha Actividad"].dt.floor("min").dt.time
df = df[(df["Numero"] != "") & df["especialidad_db"].notna()].copy()
return df
# -----------------------------
# 4) BD
# -----------------------------
def _get_db_url(args_db_url: Optional[str]) -> Optional[str]:
if args_db_url:
return args_db_url
if os.getenv("DATABASE_URL"):
return os.getenv("DATABASE_URL")
host = os.getenv("PGHOST")
user = os.getenv("PGUSER")
pwd = os.getenv("PGPASSWORD")
db = os.getenv("PGDATABASE")
port = os.getenv("PGPORT", "5432")
if all([host, user, pwd, db]):
return f"postgresql://{user}:{pwd}@{host}:{port}/{db}"
return None
def conectar(db_url: str):
if psycopg2 is None:
raise RuntimeError("Instala: python -m pip install psycopg2-binary")
return psycopg2.connect(db_url)
def preparar_bd(conn):
sql = """
UPDATE cita SET hora_cita = '00:00'::time WHERE hora_cita IS NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_cita_paciente_esp_fecha_hora'
) THEN
ALTER TABLE cita
ADD CONSTRAINT uq_cita_paciente_esp_fecha_hora
UNIQUE (numero_documento, id_especialidad, fecha_cita, hora_cita);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_cita_doc_fecha ON cita (numero_documento, fecha_cita);
"""
with conn.cursor() as cur:
cur.execute(sql)
conn.commit()
def cargar_mapa_especialidades(conn) -> Dict[str, int]:
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
cur.execute("SELECT id_especialidad, nombre FROM especialidad;")
rows = cur.fetchall()
return {_norm(r["nombre"]): int(r["id_especialidad"]) for r in rows}
def detectar_columna_plantilla(conn) -> Optional[str]:
"""
Devuelve el nombre real de la columna de planilla en paciente:
numero_plantilla / numero_planilla / numero_palntilla
"""
candidatos = ["numero_plantilla", "numero_planilla", "numero_palntilla"]
sql = """
SELECT column_name
FROM information_schema.columns
WHERE table_schema='public'
AND table_name='paciente'
AND column_name = ANY(%s);
"""
with conn.cursor() as cur:
cur.execute(sql, (candidatos,))
rows = [r[0] for r in cur.fetchall()]
for c in candidatos:
if c in rows:
return c
return None
def upsert_pacientes_desde_planilla(conn, df_plan: pd.DataFrame, col_planilla_bd: Optional[str]):
if df_plan.empty:
return
sub = df_plan.copy()
sub["doc"] = sub["Documento"].astype(str).str.strip()
agg = {
"tipo_documento": _primero_no_vacio,
"Paciente": _primero_no_vacio,
"Celular": _primero_no_vacio,
"Correo": _primero_no_vacio,
"numero_plantilla": lambda s: next((x for x in s.tolist() if pd.notna(x)), None),
}
sub = sub.groupby("doc", as_index=False).agg(agg)
# armar SQL dinámico según exista columna de planilla en BD
if col_planilla_bd:
sql = f"""
INSERT INTO paciente (numero_documento, tipo_documento, {col_planilla_bd}, nombre_completo, celular, correo)
VALUES (%s,%s,%s,%s,%s,%s)
ON CONFLICT (numero_documento)
DO UPDATE SET
tipo_documento = COALESCE(NULLIF(EXCLUDED.tipo_documento,''), paciente.tipo_documento),
{col_planilla_bd} = COALESCE(EXCLUDED.{col_planilla_bd}, paciente.{col_planilla_bd}),
nombre_completo = COALESCE(NULLIF(EXCLUDED.nombre_completo,''), paciente.nombre_completo),
celular = COALESCE(NULLIF(EXCLUDED.celular,''), paciente.celular),
correo = COALESCE(NULLIF(EXCLUDED.correo,''), paciente.correo);
"""
else:
sql = """
INSERT INTO paciente (numero_documento, tipo_documento, nombre_completo, celular, correo)
VALUES (%s,%s,%s,%s,%s)
ON CONFLICT (numero_documento)
DO UPDATE SET
tipo_documento = COALESCE(NULLIF(EXCLUDED.tipo_documento,''), paciente.tipo_documento),
nombre_completo = COALESCE(NULLIF(EXCLUDED.nombre_completo,''), paciente.nombre_completo),
celular = COALESCE(NULLIF(EXCLUDED.celular,''), paciente.celular),
correo = COALESCE(NULLIF(EXCLUDED.correo,''), paciente.correo);
"""
data = []
for _, r in sub.iterrows():
doc = _limpiar_texto(r["doc"])
if not doc:
continue
tdoc = _limpiar_texto(r.get("tipo_documento", ""))
nom = _limpiar_texto(r.get("Paciente", ""))
cel = _limpiar_texto(r.get("Celular", ""))
cor = _limpiar_texto(r.get("Correo", ""))
if col_planilla_bd:
nplan = r.get("numero_plantilla", None)
data.append((doc, tdoc, nplan, nom, cel, cor))
else:
data.append((doc, tdoc, nom, cel, cor))
with conn.cursor() as cur:
psycopg2.extras.execute_batch(cur, sql, data, page_size=800)
conn.commit()
print(f"✅ Pacientes PLANILLA upsert: {len(data)}")
def upsert_pacientes_desde_histor(conn, df_hist: pd.DataFrame):
if df_hist.empty:
return
sub = df_hist.copy()
sub["doc"] = sub["Numero"].astype(str).str.strip()
sub = sub.groupby("doc", as_index=False).agg({"tipo_documento": _primero_no_vacio})
sql = """
INSERT INTO paciente (numero_documento, tipo_documento, nombre_completo, celular, correo)
VALUES (%s,%s,'','','')
ON CONFLICT (numero_documento)
DO UPDATE SET
tipo_documento = COALESCE(NULLIF(EXCLUDED.tipo_documento,''), paciente.tipo_documento);
"""
data = []
for _, r in sub.iterrows():
doc = _limpiar_texto(r["doc"])
if not doc:
continue
tdoc = _limpiar_texto(r.get("tipo_documento", ""))
data.append((doc, tdoc))
with conn.cursor() as cur:
psycopg2.extras.execute_batch(cur, sql, data, page_size=1200)
conn.commit()
print(f"✅ Pacientes HISTOR asegurados: {len(data)}")
def upsert_citas_planilla(conn, df_plan: pd.DataFrame, mapa_esp: Dict[str, int]):
sql = """
INSERT INTO cita (
numero_documento, id_especialidad, fecha_cita, hora_cita,
tipo_cita, modalidad, asistio, observaciones
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (numero_documento, id_especialidad, fecha_cita, hora_cita)
DO UPDATE SET
modalidad = EXCLUDED.modalidad,
tipo_cita = COALESCE(EXCLUDED.tipo_cita, cita.tipo_cita),
observaciones = EXCLUDED.observaciones
RETURNING id_cita;
"""
data = []
for _, r in df_plan.iterrows():
esp = r["especialidad_db"]
esp_id = mapa_esp.get(_norm(esp))
if not esp_id:
continue
obs = {
"fuente": "PLANILLA",
"sede": _limpiar_texto(r.get("Sede", "")),
"profesional": _limpiar_texto(r.get("Nombre Profesional", "")),
"estado_planilla": _limpiar_texto(r.get("Estado", "")),
"sheet": _limpiar_texto(r.get("__sheet", "")),
}
obs_txt = " | ".join([f"{k}={v}" for k, v in obs.items() if v])
data.append((
_limpiar_texto(r["Documento"]),
esp_id,
r["fecha_cita"],
r["hora_cita"],
None,
r["modalidad_db"],
False,
obs_txt
))
with conn.cursor() as cur:
for i in range(0, len(data), 700):
bloque = data[i:i+700]
psycopg2.extras.execute_batch(cur, sql, bloque, page_size=300)
conn.commit()
print(f"✅ Citas PLANILLA upsert: {len(data)}")
def marcar_asistencia_histor(conn, df_hist: pd.DataFrame, mapa_esp: Dict[str, int], tolerancia_min: int = 60) -> pd.DataFrame:
resultados = []
sql_buscar_exacta = """
SELECT id_cita
FROM cita
WHERE numero_documento::text = %s
AND id_especialidad = %s
AND fecha_cita = %s
AND hora_cita = %s::time
LIMIT 1;
"""
sql_buscar_cercana = """
SELECT id_cita,
ABS(EXTRACT(EPOCH FROM (hora_cita - %s::time))) AS diff
FROM cita
WHERE numero_documento::text = %s
AND id_especialidad = %s
AND fecha_cita = %s
AND hora_cita IS NOT NULL
ORDER BY diff ASC
LIMIT 1;
"""
sql_update = """
UPDATE cita
SET asistio = TRUE
WHERE id_cita = %s
RETURNING id_cita;
"""
sql_insert = """
INSERT INTO cita (
numero_documento, id_especialidad, fecha_cita, hora_cita,
tipo_cita, modalidad, asistio, observaciones
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (numero_documento, id_especialidad, fecha_cita, hora_cita)
DO UPDATE SET asistio = TRUE
RETURNING id_cita;
"""
tolerancia_seg = tolerancia_min * 60
with conn.cursor() as cur:
for _, r in df_hist.iterrows():
doc = _limpiar_texto(r["Numero"])
esp = r["especialidad_db"]
esp_id = mapa_esp.get(_norm(esp))
if not doc or not esp_id:
resultados.append({**r.to_dict(), "accion": "SIN_DOC_O_ESPECIALIDAD"})
continue
f = r["fecha_cita"]
h = r["hora_cita"]
cur.execute(sql_buscar_exacta, (doc, esp_id, f, h.strftime("%H:%M")))
row = cur.fetchone()
if row:
cur.execute(sql_update, (row[0],))
resultados.append({**r.to_dict(), "accion": "UPDATE_EXACTA", "id_cita": row[0]})
continue
cur.execute(sql_buscar_cercana, (h.strftime("%H:%M"), doc, esp_id, f))
row = cur.fetchone()
if row and row[1] is not None and float(row[1]) <= tolerancia_seg:
cur.execute(sql_update, (row[0],))
resultados.append({**r.to_dict(), "accion": "UPDATE_CERCANA", "id_cita": row[0], "diff_seg": float(row[1])})
continue
obs = "fuente=HISTOR"
cur.execute(sql_insert, (
doc, esp_id, f, h.strftime("%H:%M"),
"HISTOR", "PRESENCIAL", True, obs
))
new_id = cur.fetchone()[0]
resultados.append({**r.to_dict(), "accion": "INSERT", "id_cita": new_id})
conn.commit()
return pd.DataFrame(resultados)
# -----------------------------
# 5) Main
# -----------------------------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--planilla", required=True)
ap.add_argument("--histor", required=True)
ap.add_argument("--db-url", default=None)
ap.add_argument("--sheets", default=None)
ap.add_argument("--tolerancia-min", type=int, default=60)
ap.add_argument("--outdir", default="salida_importacion")
args = ap.parse_args()
os.makedirs(args.outdir, exist_ok=True)
sheets = None
if args.sheets:
sheets = [s.strip() for s in args.sheets.split(",") if s.strip()]
print("📥 Leyendo PLANILLA...")
df_plan = cargar_planilla(args.planilla, sheets=sheets)
print(" Filas válidas:", len(df_plan))
print("📥 Leyendo HISTOR...")
df_hist = cargar_histor(args.histor)
print(" Filas válidas:", len(df_hist))
db_url = _get_db_url(args.db_url)
if not db_url:
raise SystemExit("❌ No se pudo determinar DB URL. Revisa tu .env")
print("🔌 Conectando a BD...")
conn = conectar(db_url)
try:
print("🧱 Preparando BD (UNIQUE/índices)...")
preparar_bd(conn)
mapa_esp = cargar_mapa_especialidades(conn)
print(" Especialidades en BD:", len(mapa_esp))
col_planilla_bd = detectar_columna_plantilla(conn)
if col_planilla_bd:
print(f"🧩 Columna de planilla detectada en BD: {col_planilla_bd}")
else:
print("⚠️ No encontré columna de planilla en paciente (numero_plantilla/numero_planilla/numero_palntilla).")
print("🧍 Upsert pacientes desde PLANILLA (sin duplicar)...")
upsert_pacientes_desde_planilla(conn, df_plan, col_planilla_bd)
print("🧍 Asegurando pacientes que aparecen en HISTOR...")
upsert_pacientes_desde_histor(conn, df_hist)
print("⬆️ Upsert de citas (PLANILLA)...")
upsert_citas_planilla(conn, df_plan, mapa_esp)
print("✅ Marcando asistencias (HISTOR)...")
df_res = marcar_asistencia_histor(conn, df_hist, mapa_esp, tolerancia_min=args.tolerancia_min)
df_plan.to_csv(os.path.join(args.outdir, "planilla_normalizada.csv"), index=False, encoding="utf-8-sig")
df_hist.to_csv(os.path.join(args.outdir, "histor_normalizada.csv"), index=False, encoding="utf-8-sig")
df_res.to_csv(os.path.join(args.outdir, "resultado_histor_vs_bd.csv"), index=False, encoding="utf-8-sig")
resumen = df_res["accion"].value_counts().reset_index()
resumen.columns = ["accion", "cantidad"]
resumen.to_csv(os.path.join(args.outdir, "resumen_acciones.csv"), index=False, encoding="utf-8-sig")
print("📄 Reportes guardados en:", args.outdir)
print(resumen)
finally:
conn.close()
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
accion,cantidad
UPDATE_CERCANA,4895
INSERT,2866
UPDATE_EXACTA,110
1 accion cantidad
2 UPDATE_CERCANA 4895
3 INSERT 2866
4 UPDATE_EXACTA 110

923
backend/src/server.js Normal file
View File

@ -0,0 +1,923 @@
// src/server.js
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { spawn } = require('child_process');
const { query } = require('./db');
require('dotenv').config();
const api = express();
const API_PORT = process.env.PORT || 2800;
// Middlewares
api.use(cors());
api.use(express.json());
// Ping simple
api.get('/', (req, res) => {
res.send('API AUNARSalud OK ✅');
});
// ==============================
// IMPORTACIÓN EXCEL (multer + estado)
// ==============================
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const STATE_FILE = path.join(__dirname, 'import_state.json');
function leerEstado() {
try {
if (!fs.existsSync(STATE_FILE)) return {};
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
} catch (e) {
return {};
}
}
function guardarEstado(s) {
fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2), 'utf-8');
}
function estadoParaFrontend(s) {
return {
planilla_cargada: !!s.planilla_path,
histor_cargada: !!s.histor_path,
planilla_nombre: s.planilla_nombre || null,
histor_nombre: s.histor_nombre || null,
ultima_sync: s.ultima_sync || null,
};
}
// storage: conserva nombre y agrega timestamp
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadsDir),
filename: (req, file, cb) => {
const base = file.originalname.replace(/[^\w.\-() ]+/g, '_');
const ts = Date.now();
cb(null, `${ts}__${base}`);
}
});
const upload = multer({ storage });
// control para no correr dos imports al tiempo
let importEnCurso = false;
function correrImportPython(planillaPath, historPath) {
return new Promise((resolve, reject) => {
const scriptPath = path.join(__dirname, 'importar_citas_cac.py');
const pythonCmd = process.env.PYTHON || 'python'; // Windows: python
const args = [
scriptPath,
'--planilla', planillaPath,
'--histor', historPath
];
const proc = spawn(pythonCmd, args, { cwd: __dirname });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (d) => stdout += d.toString());
proc.stderr.on('data', (d) => stderr += d.toString());
proc.on('close', (code) => {
if (code === 0) resolve({ code, stdout, stderr });
else reject({ code, stdout, stderr });
});
});
}
// GET estado
api.get('/api/importacion/estado', (req, res) => {
const s = leerEstado();
res.json(estadoParaFrontend(s));
});
// POST planilla
api.post('/api/importacion/planilla', upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No llegó archivo (file).' });
const s = leerEstado();
s.planilla_path = req.file.path;
s.planilla_nombre = req.file.originalname;
guardarEstado(s);
// si ya está histor => correr import
if (s.histor_path) {
if (importEnCurso) {
return res.status(409).json({ ok: false, mensaje: 'Importación en curso, intenta en 30s.' });
}
importEnCurso = true;
try {
const r = await correrImportPython(s.planilla_path, s.histor_path);
const s2 = leerEstado();
s2.ultima_sync = new Date().toISOString();
guardarEstado(s2);
return res.json({
ok: true,
mensaje: '✅ Planilla cargada y sincronización ejecutada.',
stdout: r.stdout,
stderr: r.stderr
});
} catch (e) {
return res.status(500).json({
ok: false,
mensaje: '❌ Falló la sincronización (Python).',
stdout: e.stdout,
stderr: e.stderr,
detalle: e
});
} finally {
importEnCurso = false;
}
}
return res.json({ ok: true, mensaje: '✅ Planilla cargada. Falta HISTOR para sincronizar.' });
} catch (err) {
console.error('Error POST /api/importacion/planilla:', err);
res.status(500).json({ ok: false, mensaje: 'Error subiendo planilla.', detalle: err?.message });
}
});
// POST histor
api.post('/api/importacion/histor', upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No llegó archivo (file).' });
const s = leerEstado();
s.histor_path = req.file.path;
s.histor_nombre = req.file.originalname;
guardarEstado(s);
// si ya está planilla => correr import
if (s.planilla_path) {
if (importEnCurso) {
return res.status(409).json({ ok: false, mensaje: 'Importación en curso, intenta en 30s.' });
}
importEnCurso = true;
try {
const r = await correrImportPython(s.planilla_path, s.histor_path);
const s2 = leerEstado();
s2.ultima_sync = new Date().toISOString();
guardarEstado(s2);
return res.json({
ok: true,
mensaje: '✅ Histor cargado y sincronización ejecutada.',
stdout: r.stdout,
stderr: r.stderr
});
} catch (e) {
return res.status(500).json({
ok: false,
mensaje: '❌ Falló la sincronización (Python).',
stdout: e.stdout,
stderr: e.stderr,
detalle: e
});
} finally {
importEnCurso = false;
}
}
return res.json({ ok: true, mensaje: '✅ Histor cargado. Falta PLANILLA para sincronizar.' });
} catch (err) {
console.error('Error POST /api/importacion/histor:', err);
res.status(500).json({ ok: false, mensaje: 'Error subiendo histor.', detalle: err?.message });
}
});
// ==============================
// HISTOGRAMAS (PLAN ANUAL REAL)
// ==============================
const PLAN_ANUAL = {
1: ['ENFERMERIA', 'LABORATORIOS', 'PSICOLOGIA', 'TRABAJO SOCIAL', 'MEDICO EXPERTO', 'QUIMICO FARMACEUTICO'],
2: ['MEDICO EXPERTO', 'NUTRICION'],
3: ['INFECTOLOGIA', 'QUIMICO FARMACEUTICO'],
4: ['MEDICO EXPERTO'],
5: ['MEDICO EXPERTO', 'ENFERMERIA', 'LABORATORIOS'],
6: ['MEDICO EXPERTO', 'ODONTOLOGIA', 'TRABAJO SOCIAL'],
7: ['MEDICO EXPERTO'],
8: ['MEDICO EXPERTO', 'OFTALMOLOGIA'],
9: ['INFECTOLOGIA', 'NUTRICION'],
10: ['MEDICO EXPERTO', 'PSIQUIATRIA'],
11: ['MEDICO EXPERTO'],
12: ['MEDICO EXPERTO'],
};
function toISO(d) {
const x = new Date(d);
const yyyy = x.getFullYear();
const mm = String(x.getMonth() + 1).padStart(2, '0');
const dd = String(x.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addMonths(date, n) {
const d = new Date(date);
const day = d.getDate();
d.setMonth(d.getMonth() + n);
if (d.getDate() < day) d.setDate(0);
return d;
}
function ajustarCicloAnual(inicio, hoy) {
let start = new Date(inicio);
let end = addMonths(start, 12);
while (end <= hoy) {
start = addMonths(start, 12);
end = addMonths(start, 12);
}
return { start, end };
}
function mesPrograma(start, hoy) {
for (let m = 1; m <= 12; m++) {
const a = addMonths(start, m - 1);
const b = addMonths(start, m);
if (hoy >= a && hoy < b) return m;
}
return 12;
}
function normEsp(s) {
if (!s) return '';
return String(s)
.trim()
.toUpperCase()
.replaceAll('Á','A').replaceAll('É','E').replaceAll('Í','I').replaceAll('Ó','O').replaceAll('Ú','U')
.replaceAll('Ñ','N');
}
function obtenerMesPorFecha(rangos, fecha) {
for (let m = 1; m <= 12; m++) {
const a = rangos[m].inicio;
const b = rangos[m].fin;
if (fecha >= a && fecha < b) return m;
}
return null;
}
// ✅ Histograma general REAL: suma esperado vs asistido según plan anual por paciente
api.get('/api/histogramas/general', async (req, res) => {
try {
const hoy = new Date();
// 1) pacientes + ingreso (tiempo) o primera cita
const pacientesRes = await query(`
SELECT
p.numero_documento::text AS doc,
t.fecha_ingreso_ips AS ingreso_tiempo,
MIN(c.fecha_cita) AS primera_cita
FROM paciente p
LEFT JOIN tiempo t ON t.numero_documento = p.numero_documento
LEFT JOIN cita c ON c.numero_documento = p.numero_documento
GROUP BY p.numero_documento, t.fecha_ingreso_ips
`);
const mapaPac = new Map();
let minStart = null;
for (const r of pacientesRes.rows) {
const doc = String(r.doc || '').trim();
const ingreso = r.ingreso_tiempo || r.primera_cita;
if (!doc || !ingreso) continue;
const ciclo = ajustarCicloAnual(new Date(ingreso), hoy);
const start = ciclo.start;
const end = ciclo.end;
const mesActual = mesPrograma(start, hoy);
const rangos = {};
for (let m = 1; m <= 12; m++) {
rangos[m] = { inicio: addMonths(start, m - 1), fin: addMonths(start, m) };
}
mapaPac.set(doc, { doc, start, end, mesActual, rangos });
if (!minStart || start < minStart) minStart = start;
}
if (!minStart) {
return res.json({
pacientes_incluidos: 0,
rango: { inicio: null, hoy: toISO(hoy) },
total_esperado: 0,
total_asistidas: 0,
porcentaje: 0,
porEspecialidad: [],
porMesPrograma: Array.from({ length: 12 }, (_, i) => ({ mes: i + 1, esperado: 0, asistidas: 0 }))
});
}
// 2) asistidas desde minStart hasta hoy
const asistidasRes = await query(
`SELECT
c.numero_documento::text AS doc,
e.nombre AS especialidad,
c.fecha_cita
FROM cita c
JOIN especialidad e ON e.id_especialidad = c.id_especialidad
WHERE c.asistio = TRUE
AND c.fecha_cita >= $1
AND c.fecha_cita <= $2`,
[toISO(minStart), toISO(hoy)]
);
// 3) laboratorios (para marcar LABORATORIOS)
const labsRes = await query(
`SELECT
numero_documento::text AS doc,
fecha_laboratorio_1, fecha_laboratorio_2, fecha_laboratorio_3, fecha_laboratorio_4,
fecha_laboratorio_5, fecha_laboratorio_6, fecha_laboratorio_7, fecha_laboratorio_8
FROM laboratorios`
);
// 4) cumplidas: máximo 1 por (doc|mes|especialidad)
const cumplidas = new Set();
for (const r of asistidasRes.rows) {
const doc = String(r.doc || '').trim();
const p = mapaPac.get(doc);
if (!p) continue;
const fecha = new Date(r.fecha_cita);
if (!(fecha >= p.start && fecha <= hoy)) continue;
const mes = obtenerMesPorFecha(p.rangos, fecha);
if (!mes || mes > p.mesActual) continue;
const esp = normEsp(r.especialidad);
const planMes = (PLAN_ANUAL[mes] || []).map(normEsp);
if (planMes.includes(esp)) {
cumplidas.add(`${doc}|${mes}|${esp}`);
}
}
// labs => LABORATORIOS
for (const r of labsRes.rows) {
const doc = String(r.doc || '').trim();
const p = mapaPac.get(doc);
if (!p) continue;
const fechas = [
r.fecha_laboratorio_1, r.fecha_laboratorio_2, r.fecha_laboratorio_3, r.fecha_laboratorio_4,
r.fecha_laboratorio_5, r.fecha_laboratorio_6, r.fecha_laboratorio_7, r.fecha_laboratorio_8
].filter(Boolean).map(x => new Date(x));
for (const f of fechas) {
if (!(f >= p.start && f <= hoy)) continue;
const mes = obtenerMesPorFecha(p.rangos, f);
if (!mes || mes > p.mesActual) continue;
const planMes = (PLAN_ANUAL[mes] || []).map(normEsp);
if (planMes.includes('LABORATORIOS')) {
cumplidas.add(`${doc}|${mes}|LABORATORIOS`);
}
}
}
// 5) esperado vs asistidas (sumado por especialidad y por mes del programa)
const esperadoPorEsp = {};
const asistidasPorEsp = {};
const porMesPrograma = Array.from({ length: 12 }, (_, i) => ({ mes: i + 1, esperado: 0, asistidas: 0 }));
let total_esperado = 0;
let total_asistidas = 0;
for (const p of mapaPac.values()) {
for (let mes = 1; mes <= p.mesActual; mes++) {
const lista = (PLAN_ANUAL[mes] || []).map(normEsp);
for (const esp of lista) {
esperadoPorEsp[esp] = (esperadoPorEsp[esp] || 0) + 1;
porMesPrograma[mes - 1].esperado += 1;
total_esperado += 1;
const key = `${p.doc}|${mes}|${esp}`;
if (cumplidas.has(key)) {
asistidasPorEsp[esp] = (asistidasPorEsp[esp] || 0) + 1;
porMesPrograma[mes - 1].asistidas += 1;
total_asistidas += 1;
}
}
}
}
const especialidades = Object.keys(esperadoPorEsp).sort();
const porEspecialidad = especialidades.map((esp) => {
const esperado = esperadoPorEsp[esp] || 0;
const asistidas = asistidasPorEsp[esp] || 0;
const pendientes = Math.max(0, esperado - asistidas);
const porcentaje = esperado ? Math.round((asistidas / esperado) * 100) : 0;
return { especialidad: esp, esperado, asistidas, pendientes, porcentaje };
});
const porcentaje = total_esperado ? Math.round((total_asistidas / total_esperado) * 100) : 0;
res.json({
pacientes_incluidos: mapaPac.size,
rango: { inicio: toISO(minStart), hoy: toISO(hoy) },
total_esperado,
total_asistidas,
porcentaje,
porEspecialidad,
porMesPrograma
});
} catch (err) {
console.error('Error GET /api/histogramas/general:', err);
res.status(500).json({ error: 'Error generando histograma general' });
}
});
// Histograma anual por paciente (CICLO ANUAL segun ingreso)
api.get('/api/histogramas/paciente/:numero_documento', async (req, res) => {
const { numero_documento } = req.params;
try {
// 1) fecha ingreso (tiempo) o primera cita
let ingreso = null;
const ingRes = await query(
`SELECT fecha_ingreso_ips
FROM tiempo
WHERE numero_documento::text = $1
LIMIT 1`,
[numero_documento]
);
if (ingRes.rows.length && ingRes.rows[0].fecha_ingreso_ips) {
ingreso = new Date(ingRes.rows[0].fecha_ingreso_ips);
} else {
const minRes = await query(
`SELECT MIN(fecha_cita) AS fecha
FROM cita
WHERE numero_documento::text = $1`,
[numero_documento]
);
if (minRes.rows.length && minRes.rows[0].fecha) ingreso = new Date(minRes.rows[0].fecha);
}
if (!ingreso) {
return res.status(404).json({ error: 'No hay fecha de ingreso ni citas para construir histograma.' });
}
const hoy = new Date();
const ciclo = ajustarCicloAnual(ingreso, hoy);
const start = ciclo.start;
const end = ciclo.end; // exclusivo
const mesActual = mesPrograma(start, hoy);
// rangos por mes
const rangos = {};
for (let m = 1; m <= 12; m++) {
rangos[m] = { inicio: addMonths(start, m - 1), fin: addMonths(start, m) };
}
// 2) citas asistidas del paciente en ciclo
const citasRes = await query(
`SELECT e.nombre AS especialidad, c.fecha_cita, c.asistio
FROM cita c
JOIN especialidad e ON e.id_especialidad = c.id_especialidad
WHERE c.numero_documento::text = $1
AND c.fecha_cita >= $2
AND c.fecha_cita < $3`,
[numero_documento, toISO(start), toISO(end)]
);
// 3) labs del paciente
const labsRes = await query(
`SELECT fecha_laboratorio_1, fecha_laboratorio_2, fecha_laboratorio_3, fecha_laboratorio_4,
fecha_laboratorio_5, fecha_laboratorio_6, fecha_laboratorio_7, fecha_laboratorio_8
FROM laboratorios
WHERE numero_documento::text = $1
LIMIT 1`,
[numero_documento]
);
const fechasLabs = [];
if (labsRes.rows.length) {
const row = labsRes.rows[0];
Object.keys(row).forEach(k => { if (row[k]) fechasLabs.push(new Date(row[k])); });
}
// cumplidas por mes (máximo 1 por especialidad en el mes)
const cumplidas = new Set();
for (const r of citasRes.rows) {
if (r.asistio !== true) continue;
const fecha = new Date(r.fecha_cita);
const mes = obtenerMesPorFecha(rangos, fecha);
if (!mes) continue;
const esp = normEsp(r.especialidad);
const planMes = (PLAN_ANUAL[mes] || []).map(normEsp);
if (planMes.includes(esp)) {
cumplidas.add(`${mes}|${esp}`);
}
}
for (const f of fechasLabs) {
const mes = obtenerMesPorFecha(rangos, f);
if (!mes) continue;
const planMes = (PLAN_ANUAL[mes] || []).map(normEsp);
if (planMes.includes('LABORATORIOS')) {
cumplidas.add(`${mes}|LABORATORIOS`);
}
}
const porMes = [];
let esperadoHastaHoy = 0;
let asistidasHastaHoy = 0;
for (let m = 1; m <= 12; m++) {
const a = rangos[m].inicio;
const b = rangos[m].fin;
const lista = (PLAN_ANUAL[m] || []).map(normEsp);
const esperado = lista.length;
let asistidas = 0;
for (const esp of lista) {
if (cumplidas.has(`${m}|${esp}`)) asistidas += 1;
}
const pendientes = Math.max(0, esperado - asistidas);
porMes.push({
mes: m,
inicio: toISO(a),
fin: toISO(new Date(b.getTime() - 86400000)),
esperado,
asistidas,
pendientes
});
if (m <= mesActual) {
esperadoHastaHoy += esperado;
asistidasHastaHoy += asistidas;
}
}
const esperadoMes = porMes.find(x => x.mes === mesActual)?.esperado || 0;
const asistidasMes = porMes.find(x => x.mes === mesActual)?.asistidas || 0;
const porcentajeMes = esperadoMes ? Math.round((asistidasMes / esperadoMes) * 100) : 0;
const porcentajeHasta = esperadoHastaHoy ? Math.round((asistidasHastaHoy / esperadoHastaHoy) * 100) : 0;
res.json({
numero_documento,
ingreso: {
fecha: toISO(ingreso),
fuente: (ingRes.rows.length && ingRes.rows[0].fecha_ingreso_ips) ? 'tiempo.fecha_ingreso_ips' : 'primera_cita'
},
ventana: {
inicio: toISO(start),
fin: toISO(new Date(end.getTime() - 86400000)),
mes_programa: mesActual
},
cumplimiento: {
esperado_mes_actual: esperadoMes,
asistidas_mes_actual: asistidasMes,
porcentaje_mes_actual: porcentajeMes,
esperado_hasta_hoy: esperadoHastaHoy,
asistidas_hasta_hoy: asistidasHastaHoy,
porcentaje_hasta_hoy: porcentajeHasta
},
porMes
});
} catch (err) {
console.error('Error GET /api/histogramas/paciente/:doc:', err);
res.status(500).json({ error: 'Error generando histograma del paciente' });
}
});
// ==============================
// TUS RUTAS EXISTENTES
// ==============================
/**
* GET /api/pacientes
*/
api.get('/api/pacientes', async (req, res) => {
const { tipo, termino } = req.query;
try {
const valor = (termino || '').toString().trim();
if (!valor) {
const resultado = await query(
`SELECT
tipo_documento,
numero_documento::text AS numero_documento,
nombre_completo,
genero,
edad,
fecha_nacimiento,
celular,
correo
FROM paciente
ORDER BY nombre_completo
LIMIT 50`
);
return res.json(resultado.rows);
}
let sql = '';
const param = `%${valor}%`;
if (tipo === 'documento') {
sql = `
SELECT
tipo_documento,
numero_documento::text AS numero_documento,
nombre_completo,
genero,
edad,
fecha_nacimiento,
celular,
correo
FROM paciente
WHERE numero_documento::text ILIKE $1
ORDER BY nombre_completo
LIMIT 50
`;
} else if (tipo === 'nombre') {
sql = `
SELECT
tipo_documento,
numero_documento::text AS numero_documento,
nombre_completo,
genero,
edad,
fecha_nacimiento,
celular,
correo
FROM paciente
WHERE nombre_completo ILIKE $1
ORDER BY nombre_completo
LIMIT 50
`;
} else {
return res.status(400).json({ error: 'Tipo de búsqueda no válido' });
}
const resultado = await query(sql, [param]);
res.json(resultado.rows);
} catch (err) {
console.error('Error en GET /api/pacientes:', err);
res.status(500).json({ error: 'Error consultando pacientes' });
}
});
/**
* GET /api/pacientes/:numero_documento/citas
*/
api.get('/api/pacientes/:numero_documento/citas', async (req, res) => {
const { numero_documento } = req.params;
try {
const resultado = await query(
`SELECT
c.id_cita,
c.numero_documento::text AS numero_documento,
e.nombre AS especialidad,
c.fecha_cita,
c.hora_cita,
c.tipo_cita,
c.modalidad,
c.asistio,
c.observaciones
FROM cita c
JOIN especialidad e
ON c.id_especialidad = e.id_especialidad
WHERE c.numero_documento::text = $1
ORDER BY c.fecha_cita DESC, c.id_cita DESC`,
[numero_documento]
);
res.json(resultado.rows);
} catch (err) {
console.error('Error en GET /api/pacientes/:numero_documento/citas:', err);
res.status(500).json({ error: 'Error consultando citas del paciente' });
}
});
/**
* GET /api/especialidades
*/
api.get('/api/especialidades', async (req, res) => {
try {
const resultado = await query(
`SELECT id_especialidad, nombre
FROM especialidad
ORDER BY id_especialidad`
);
res.json(resultado.rows);
} catch (err) {
console.error('Error en GET /api/especialidades:', err);
res.status(500).json({ error: 'Error consultando especialidades' });
}
});
/**
* POST /api/citas
*/
api.post('/api/citas', async (req, res) => {
const {
numero_documento,
id_especialidad,
fecha_cita,
hora_cita,
tipo_cita,
modalidad,
asistio,
observaciones
} = req.body;
if (!numero_documento || !id_especialidad || !fecha_cita || !hora_cita || !modalidad) {
return res.status(400).json({
error: 'numero_documento, id_especialidad, fecha_cita, hora_cita y modalidad son obligatorios'
});
}
try {
const conflictoMismaEsp = await query(
`SELECT 1
FROM cita
WHERE numero_documento::text = $1
AND id_especialidad = $2
AND fecha_cita = $3
AND hora_cita = $4::time
LIMIT 1`,
[numero_documento, id_especialidad, fecha_cita, hora_cita]
);
if (conflictoMismaEsp.rows.length > 0) {
return res.status(400).json({
error: 'Ya existe una cita para este paciente, en esta especialidad, fecha y hora.'
});
}
const conflicto30m = await query(
`SELECT 1
FROM cita
WHERE numero_documento::text = $1
AND fecha_cita = $2
AND hora_cita IS NOT NULL
AND ABS(EXTRACT(EPOCH FROM (hora_cita - $3::time))) < 1800
LIMIT 1`,
[numero_documento, fecha_cita, hora_cita]
);
if (conflicto30m.rows.length > 0) {
return res.status(400).json({
error: 'El paciente ya tiene otra cita ese día en un horario muy cercano (menos de 30 minutos).'
});
}
const countRes = await query(
'SELECT COUNT(*) AS total FROM cita WHERE id_especialidad = $1',
[id_especialidad]
);
const total = Number(countRes.rows[0].total) || 0;
const consecutivo = total + 1;
const nuevoId = id_especialidad * 1_000_000 + consecutivo;
const valorAsistio = typeof asistio === 'boolean' ? asistio : false;
const existeRes = await query('SELECT 1 FROM cita WHERE id_cita = $1', [nuevoId]);
if (existeRes.rows.length > 0) {
return res.status(500).json({
error: 'Conflicto al generar id_cita, ya existe un registro con ese ID'
});
}
const resultado = await query(
`WITH nueva AS (
INSERT INTO cita (
id_cita,
numero_documento,
id_especialidad,
fecha_cita,
hora_cita,
tipo_cita,
modalidad,
asistio,
observaciones
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *
)
SELECT
n.id_cita,
n.numero_documento::text AS numero_documento,
e.nombre AS especialidad,
n.fecha_cita,
n.hora_cita,
n.tipo_cita,
n.modalidad,
n.asistio,
n.observaciones
FROM nueva n
JOIN especialidad e ON n.id_especialidad = e.id_especialidad`,
[
nuevoId,
numero_documento,
id_especialidad,
fecha_cita,
hora_cita,
tipo_cita || null,
modalidad,
valorAsistio,
observaciones || null
]
);
res.status(201).json(resultado.rows[0]);
} catch (err) {
console.error('Error en POST /api/citas:', err);
res.status(500).json({ error: 'Error creando la cita' });
}
});
/**
* PATCH /api/citas/:id_cita/asistencia
*/
api.patch('/api/citas/:id_cita/asistencia', async (req, res) => {
const { id_cita } = req.params;
const { asistio } = req.body;
if (typeof asistio !== 'boolean') {
return res.status(400).json({ error: 'El campo asistio debe ser booleano' });
}
try {
const resultado = await query(
`
WITH datos AS (
SELECT numero_documento
FROM cita
WHERE id_cita = $2
),
ultimas AS (
SELECT c.id_cita
FROM cita c
JOIN datos d ON c.numero_documento = d.numero_documento
ORDER BY c.fecha_cita DESC, c.id_cita DESC
LIMIT 2
),
actualizada AS (
UPDATE cita c
SET asistio = $1
WHERE c.id_cita IN (SELECT id_cita FROM ultimas)
AND c.id_cita = $2
RETURNING *
)
SELECT
a.id_cita,
a.numero_documento::text AS numero_documento,
e.nombre AS especialidad,
a.fecha_cita,
a.hora_cita,
a.tipo_cita,
a.modalidad,
a.asistio,
a.observaciones
FROM actualizada a
JOIN especialidad e ON a.id_especialidad = e.id_especialidad;
`,
[asistio, id_cita]
);
if (resultado.rows.length === 0) {
return res.status(400).json({
error: 'Solo se puede modificar la asistencia de las dos últimas citas del paciente'
});
}
res.json(resultado.rows[0]);
} catch (err) {
console.error('Error en PATCH /api/citas/:id_cita/asistencia:', err);
res.status(500).json({ error: 'Error actualizando asistencia' });
}
});
// Arrancar servidor
api.listen(API_PORT, () => {
console.log(`Servidor escuchando en http://localhost:${API_PORT}`);
});