Aunar
This commit is contained in:
commit
02e3afa684
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal 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
59
README.md
Normal 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
17
aunarsalud/.editorconfig
Normal 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
4
aunarsalud/.vscode/extensions.json
vendored
Normal 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
20
aunarsalud/.vscode/launch.json
vendored
Normal 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
42
aunarsalud/.vscode/tasks.json
vendored
Normal 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
74
aunarsalud/angular.json
Normal 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
9352
aunarsalud/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
aunarsalud/package.json
Normal file
44
aunarsalud/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
aunarsalud/public/favicon.ico
Normal file
BIN
aunarsalud/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
11
aunarsalud/src/app/app.config.ts
Normal file
11
aunarsalud/src/app/app.config.ts
Normal 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)
|
||||
]
|
||||
};
|
||||
0
aunarsalud/src/app/app.css
Normal file
0
aunarsalud/src/app/app.css
Normal file
326
aunarsalud/src/app/app.html
Normal file
326
aunarsalud/src/app/app.html
Normal 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>
|
||||
3
aunarsalud/src/app/app.routes.ts
Normal file
3
aunarsalud/src/app/app.routes.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
23
aunarsalud/src/app/app.spec.ts
Normal file
23
aunarsalud/src/app/app.spec.ts
Normal 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
521
aunarsalud/src/app/app.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
9
aunarsalud/src/app/modelos/cita.ts
Normal file
9
aunarsalud/src/app/modelos/cita.ts
Normal 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;
|
||||
}
|
||||
7
aunarsalud/src/app/modelos/paciente.ts
Normal file
7
aunarsalud/src/app/modelos/paciente.ts
Normal 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'
|
||||
}
|
||||
98
aunarsalud/src/app/servicios/importacion.ts
Normal file
98
aunarsalud/src/app/servicios/importacion.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
16
aunarsalud/src/app/servicios/paciente.spec.ts
Normal file
16
aunarsalud/src/app/servicios/paciente.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
92
aunarsalud/src/app/servicios/paciente.ts
Normal file
92
aunarsalud/src/app/servicios/paciente.ts
Normal 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
13
aunarsalud/src/index.html
Normal 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
6
aunarsalud/src/main.ts
Normal 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
110
aunarsalud/src/styles.css
Normal 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;
|
||||
}
|
||||
15
aunarsalud/tsconfig.app.json
Normal file
15
aunarsalud/tsconfig.app.json
Normal 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
33
aunarsalud/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
aunarsalud/tsconfig.spec.json
Normal file
15
aunarsalud/tsconfig.spec.json
Normal 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
1923
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
backend/package.json
Normal file
24
backend/package.json
Normal 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"
|
||||
}
|
||||
BIN
backend/src/-ACTIVIDADESREALIZADASENHISTOR_2025-12-08-09-02.xlsx
Normal file
BIN
backend/src/-ACTIVIDADESREALIZADASENHISTOR_2025-12-08-09-02.xlsx
Normal file
Binary file not shown.
BIN
backend/src/PLANILLA DE CITAS NOVIEMBRE.xlsx
Normal file
BIN
backend/src/PLANILLA DE CITAS NOVIEMBRE.xlsx
Normal file
Binary file not shown.
22
backend/src/db.js
Normal file
22
backend/src/db.js
Normal 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
|
||||
};
|
||||
636
backend/src/importar_citas_cac.py
Normal file
636
backend/src/importar_citas_cac.py
Normal 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()
|
||||
7872
backend/src/salida_importacion/histor_normalizada.csv
Normal file
7872
backend/src/salida_importacion/histor_normalizada.csv
Normal file
File diff suppressed because it is too large
Load Diff
9510
backend/src/salida_importacion/planilla_normalizada.csv
Normal file
9510
backend/src/salida_importacion/planilla_normalizada.csv
Normal file
File diff suppressed because it is too large
Load Diff
7872
backend/src/salida_importacion/resultado_histor_vs_bd.csv
Normal file
7872
backend/src/salida_importacion/resultado_histor_vs_bd.csv
Normal file
File diff suppressed because it is too large
Load Diff
4
backend/src/salida_importacion/resumen_acciones.csv
Normal file
4
backend/src/salida_importacion/resumen_acciones.csv
Normal file
@ -0,0 +1,4 @@
|
||||
accion,cantidad
|
||||
UPDATE_CERCANA,4895
|
||||
INSERT,2866
|
||||
UPDATE_EXACTA,110
|
||||
|
923
backend/src/server.js
Normal file
923
backend/src/server.js
Normal 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}`);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user