Compare commits
156 Commits
78742dc906
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c0812efe9 | |||
| 7fd37d0992 | |||
| 49d3ef9db1 | |||
| 7111550fb7 | |||
| f73319046e | |||
| e9a184f087 | |||
| 954c5b4a7b | |||
| 6455d351dd | |||
| ae3a50054a | |||
| 387897acfc | |||
| 56decba945 | |||
| 65a74cf754 | |||
| 289b4b6b93 | |||
| a1ab955556 | |||
| 14d5027170 | |||
| a8afaa044f | |||
| 7f2e9add29 | |||
| 24eb039302 | |||
| 9de059e9ca | |||
| 9ed41c9ee4 | |||
| b191030321 | |||
| 023a004c53 | |||
| 59a0f56b99 | |||
| 3bf8b44581 | |||
| 311d363e31 | |||
| d3676172e1 | |||
| e3524b32d4 | |||
| 44cd81956f | |||
| 58bf1bfc69 | |||
| 50909e4499 | |||
| 582114a55a | |||
| c4f5d960de | |||
| 35b419a654 | |||
| fce31467d8 | |||
| c6a6ba976e | |||
| 31f5edae84 | |||
| de5f09a351 | |||
| 7f50bfd8c6 | |||
| c374909fa8 | |||
| bf30b1a2bf | |||
| d51d912962 | |||
| 1450d443d4 | |||
| 1988ec95da | |||
| d86a216766 | |||
| 4174774702 | |||
| 54006d5756 | |||
| c226fbd34b | |||
| b2398efead | |||
| 14a64778b8 | |||
| a692948a6f | |||
| 45ad650bac | |||
| 7820f143ac | |||
| 2db2833f27 | |||
| 7b39648be5 | |||
| c76f803871 | |||
| b6440130ac | |||
| 886f0bafbd | |||
| 00218a1a92 | |||
| ed037ef4cc | |||
| 37daf6b8d3 | |||
| f57d7328e1 | |||
| 6d006d3b13 | |||
| 96426a4259 | |||
| 16f431cbad | |||
| 91711ac95f | |||
| 4e70f1f9b0 | |||
| 14b3376a4a | |||
| 185b9fc631 | |||
| 320f41c0ff | |||
| e79aa1f212 | |||
| 34221c4726 | |||
| 58672c52d7 | |||
| 416588a327 | |||
| 32c7f79dd6 | |||
| 1c9d7348ed | |||
| ce151631ab | |||
| 2d520e03d6 | |||
| bd2b11d543 | |||
| 97c5aab93d | |||
| d6c0f117a1 | |||
| 651aa138cf | |||
| 826c5fce5e | |||
| ed3f513075 | |||
| 027f22551c | |||
| 0117ba34f8 | |||
| efbf57e6bc | |||
| afe57fba1d | |||
| 409cbd437a | |||
| ac17c26c66 | |||
| ef9c37dcdd | |||
| e3ac1c84d7 | |||
| aa35c8f2eb | |||
| d1b4d10257 | |||
| 7fb2e40a1e | |||
| fdad7b10ad | |||
| 162b278044 | |||
| d8f1c3de10 | |||
| e3adb34960 | |||
| 1e5ba305ae | |||
| da2469b39e | |||
| cdd1b3507b | |||
| 6f3a6d40f4 | |||
| f4b33ce014 | |||
| 822615d2ba | |||
| 6999416be9 | |||
| 16a25799e9 | |||
| 637b21d85f | |||
| aa2b196b53 | |||
| ac53303930 | |||
| 4d3ac4bb5c | |||
| 70f984bfdf | |||
| d35f0343e1 | |||
| 38dbb07eec | |||
| 0987becc25 | |||
| 5b82418f0a | |||
| 822ab5a1cb | |||
| cbfab59222 | |||
| 83e76f02a2 | |||
| 26ed0eb4f0 | |||
| b867f11450 | |||
| 98f0d94564 | |||
| 683e260c79 | |||
| 1c4bd93105 | |||
| 773a9336ef | |||
| 4c8938a24e | |||
| 1ef07ad2c5 | |||
| 99f0952378 | |||
| 195143b7ab | |||
| e373348ea6 | |||
| 8116bb5956 | |||
| 1ed59058f7 | |||
| e8d3e7ef7b | |||
| 1b31007eef | |||
| eb94d8ccfc | |||
| ad59152cce | |||
| 093256382c | |||
| b6c7a9ed63 | |||
| 0b0763cfff | |||
| 14073db2d9 | |||
| 33b134e838 | |||
| bfe542159d | |||
| b1c0e05306 | |||
| cfef4f6f89 | |||
| 855016c63d | |||
| 4234b71e17 | |||
| b304fbbb86 | |||
| 4428d17b27 | |||
| 7788e869db | |||
| de5900a4ab | |||
| 871f81277c | |||
| 9c55f45c8a | |||
| 3100473eb6 | |||
| ac7b582d8a | |||
| 2b5424790a | |||
| 678a8cd24b | |||
| b730c4f7c2 |
@@ -1,7 +1,7 @@
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgresql://checklist_user:checklist_pass_2024@localhost:5432/checklist_db
|
DATABASE_URL=postgresql://checklist_user:checklist_pass_2024@localhost:5432/checklist_db
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
SECRET_KEY=your-super-secret-key-min-32-characters-change-this
|
SECRET_KEY=your-super-secret-key-min-32-characters-change-this
|
||||||
ALGORITHM=HS256
|
ALGORITHM=HS256
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||||
|
|||||||
133
.github/copilot-instructions.md
vendored
Normal file
133
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# No olvides dar lo comentarios de los cambios que se hicieron para el backend y el front en para los comentarios de git
|
||||||
|
# Siempre actuliza la version del front y del back la version del front esta en el archivo package.json y la del backend en el archivo main.py en una variable llamada BACKEND_VERSION
|
||||||
|
# Si el FrontEnd no sufre modificaciones no es necesario actualizar su version, al igual que el backend, solo poner en el comentario de git que no se hicieron cambios en el front o en el backend segun sea el caso
|
||||||
|
|
||||||
|
# Ayudetec - Intelligent Checklist System for Automotive Workshops
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
**Tech Stack**: FastAPI (Python 3.11) + React 18 + PostgreSQL 15 + MinIO/S3
|
||||||
|
**Deployment**: Docker Compose for dev, Docker Stack for production
|
||||||
|
**Key Feature**: AI-powered inspection analysis with OpenAI/Gemini integration
|
||||||
|
|
||||||
|
### Service Structure
|
||||||
|
- `backend/` - FastAPI monolith with JWT auth, S3 file storage, PDF generation
|
||||||
|
- `frontend/` - React SPA with Vite, TailwindCSS, client-side routing
|
||||||
|
- `postgres` - Main data store with checklist templates, inspections, answers
|
||||||
|
- MinIO/S3 - Image and PDF storage (configurable endpoint)
|
||||||
|
|
||||||
|
### Core Data Model
|
||||||
|
**Users** (`role`: admin/mechanic/asesor) → **Checklists** (templates with questions) → **Inspections** (mechanic executions) → **Answers** (responses with photos/scores)
|
||||||
|
|
||||||
|
- **Permissions**: `ChecklistPermission` table controls mechanic access (empty = global access)
|
||||||
|
- **Nested Questions**: 5-level deep conditional subquestions via `parent_question_id` + `show_if_answer`
|
||||||
|
- **Scoring**: Auto-calculated from answer points, stored in `Inspection.score/percentage`
|
||||||
|
- **Question Types**: boolean, single_choice, multiple_choice, scale, text, number, date, time (config in `Question.options` JSON)
|
||||||
|
|
||||||
|
## Development Workflows
|
||||||
|
|
||||||
|
### Running Locally
|
||||||
|
```powershell
|
||||||
|
docker-compose up -d # Start all services
|
||||||
|
docker-compose logs backend # Debug backend issues
|
||||||
|
```
|
||||||
|
**URLs**: Frontend `http://localhost:5173`, Backend API `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
### Database Initialization
|
||||||
|
Use `init_users.py` to create default admin/mechanic users:
|
||||||
|
```powershell
|
||||||
|
docker-compose exec backend python init_users.py
|
||||||
|
```
|
||||||
|
**Default credentials**: `admin/admin123`, `mecanico/mec123`
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
Manual SQL scripts in `migrations/` - run via:
|
||||||
|
```powershell
|
||||||
|
docker-compose exec backend python -c "
|
||||||
|
from app.core.database import engine
|
||||||
|
with open('migrations/your_migration.sql') as f:
|
||||||
|
engine.execute(f.read())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
```powershell
|
||||||
|
.\build-and-push.ps1 # Builds images, pushes to Docker Hub (configured in script)
|
||||||
|
```
|
||||||
|
Then deploy with `docker-compose.prod.yml` or `docker-stack.yml` (Swarm mode)
|
||||||
|
|
||||||
|
## Project-Specific Conventions
|
||||||
|
|
||||||
|
### API Architecture (`backend/app/main.py`)
|
||||||
|
- **Single 2800+ line file** - all endpoints in main.py (no routers/controllers split)
|
||||||
|
- Auth via `get_current_user()` dependency returning `models.User`
|
||||||
|
- File uploads use boto3 S3 client configured from `app/core/config.py` (MinIO compatible)
|
||||||
|
- PDF generation inline with ReportLab (starts ~line 1208, function `generate_pdf`)
|
||||||
|
|
||||||
|
### AI Integration Modes (see `AI_FUNCTIONALITY.md`)
|
||||||
|
- `ai_mode` on Checklist: `"off"` (manual) | `"assisted"` (suggestions) | `"copilot"` (auto-complete)
|
||||||
|
- AI analysis triggered on photo upload, stored in `Answer.ai_analysis` JSON
|
||||||
|
- Webhook notifications to n8n on completion: `send_completed_inspection_to_n8n()`
|
||||||
|
|
||||||
|
### Frontend Patterns (`frontend/src/App.jsx`)
|
||||||
|
- **5400+ line single-file component** - all views in App.jsx (Login, Dashboard, Admin panels)
|
||||||
|
- Auth state in `localStorage` (token + user object)
|
||||||
|
- API calls use `fetch()` with `import.meta.env.VITE_API_URL` base
|
||||||
|
- Signature capture via `react-signature-canvas` (saved as base64)
|
||||||
|
|
||||||
|
### Permission Model
|
||||||
|
When fetching checklists:
|
||||||
|
- Admins see all checklists
|
||||||
|
- Mechanics only see checklists with either:
|
||||||
|
- No `ChecklistPermission` records (global access), OR
|
||||||
|
- A permission record linking that mechanic
|
||||||
|
- Implement via JOIN in `GET /api/checklists` endpoint
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
Critical settings in `.env`:
|
||||||
|
- `DATABASE_URL` - Postgres connection string
|
||||||
|
- `SECRET_KEY` - JWT signing (min 32 chars)
|
||||||
|
- `OPENAI_API_KEY` / `GEMINI_API_KEY` - Optional for AI features
|
||||||
|
- `MINIO_*` vars - S3-compatible storage (MinIO/AWS S3)
|
||||||
|
- `NOTIFICACION_ENDPOINT` - n8n webhook for inspection events
|
||||||
|
- `ALLOWED_ORIGINS` - Comma-separated CORS origins
|
||||||
|
|
||||||
|
## Key Integration Points
|
||||||
|
|
||||||
|
### S3/MinIO Storage
|
||||||
|
- Configured globally in `main.py` via `boto3.client()` + `Config(signature_version='s3v4')`
|
||||||
|
- Two buckets: `MINIO_IMAGE_BUCKET` (photos), `MINIO_PDF_BUCKET` (reports)
|
||||||
|
- Upload pattern: generate UUID filename, `s3_client.upload_fileobj()`, store URL in DB
|
||||||
|
|
||||||
|
### PDF Generation
|
||||||
|
- ReportLab library generates inspection reports with photos, signatures, scoring
|
||||||
|
- Triggered on inspection completion, stored to S3, URL saved in `Inspection.pdf_url`
|
||||||
|
- Uses checklist logo from `Checklist.logo_url` if available
|
||||||
|
|
||||||
|
### Webhook Notifications
|
||||||
|
When `Question.send_notification = true`, answering triggers `send_answer_notification()` to `NOTIFICACION_ENDPOINT`
|
||||||
|
On inspection completion, sends full data via `send_completed_inspection_to_n8n()`
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
|
||||||
|
- **No Alembic auto-migrations** - use manual SQL scripts in `migrations/`
|
||||||
|
- **Token expiry is 7 days** - set in `config.ACCESS_TOKEN_EXPIRE_MINUTES = 10080`
|
||||||
|
- **Photos stored as S3 URLs** - not base64 in DB (except signatures)
|
||||||
|
- **Nested questions limited to 5 levels** - enforced in `Question.depth_level`
|
||||||
|
- **PowerShell scripts** - Windows-first project (see `.ps1` build scripts)
|
||||||
|
- **Frontend has no state management** - uses React `useState` only, no Redux/Context
|
||||||
|
|
||||||
|
## Checklist Management
|
||||||
|
|
||||||
|
### Editing Checklists
|
||||||
|
- Admins can edit checklist name, description, AI mode, and scoring settings via "✏️ Editar" button
|
||||||
|
- Edit modal (`showEditChecklistModal`) uses PUT `/api/checklists/{id}` endpoint
|
||||||
|
- Backend endpoint supports partial updates via `exclude_unset=True` in Pydantic model
|
||||||
|
- Logo and permissions have separate management modals for focused UI
|
||||||
|
|
||||||
|
## Example Tasks
|
||||||
|
|
||||||
|
**Add new question type**: Update `Question.type` validation, modify `QuestionTypeEditor.jsx` UI, handle in `QuestionAnswerInput.jsx`
|
||||||
|
**Change scoring logic**: Edit `backend/app/main.py` answer submission endpoint, recalculate `Inspection.score`
|
||||||
|
**Add new user role**: Update `User.role` enum, modify `get_current_user()` checks, adjust frontend role conditionals in `App.jsx`
|
||||||
|
**Edit checklist properties**: Use existing PUT endpoint, add fields to `editChecklistData` state, update modal form
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -29,6 +29,8 @@ dist-ssr/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
375
AUDITORIA_INSPECCIONES.md
Normal file
375
AUDITORIA_INSPECCIONES.md
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
# Sistema de Auditoría y Edición de Inspecciones
|
||||||
|
|
||||||
|
## ✅ Implementación Completa
|
||||||
|
|
||||||
|
Se ha implementado un sistema completo de auditoría que permite a los administradores editar inspecciones completadas y mantener un registro detallado de todos los cambios realizados.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Características Implementadas
|
||||||
|
|
||||||
|
### **Backend**
|
||||||
|
|
||||||
|
1. **Modelo de Auditoría**
|
||||||
|
- Tabla `inspection_audit_log` que registra todos los cambios
|
||||||
|
- Campos: inspection_id, answer_id, user_id, action, entity_type, field_name, old_value, new_value, comment, created_at
|
||||||
|
- Relaciones con inspections, answers y users
|
||||||
|
|
||||||
|
2. **Endpoints de Auditoría**
|
||||||
|
- `GET /api/inspections/{id}/audit-log` - Obtener historial de cambios (solo admin)
|
||||||
|
- `PUT /api/answers/{id}/admin-edit` - Editar respuesta con registro automático (solo admin)
|
||||||
|
|
||||||
|
3. **Registro Automático**
|
||||||
|
- Cada cambio registra: qué se cambió, valor anterior, valor nuevo, quién lo cambió, cuándo y por qué
|
||||||
|
- Recalcula puntos automáticamente si cambia el status
|
||||||
|
- Registra múltiples cambios en una sola edición
|
||||||
|
|
||||||
|
### **Frontend**
|
||||||
|
|
||||||
|
1. **Edición de Respuestas (Solo Admin)**
|
||||||
|
- Botón "✏️ Editar" en cada respuesta de inspecciones completadas
|
||||||
|
- Formulario inline con campos editables:
|
||||||
|
- Estado (OK, Advertencia, Crítico, N/A)
|
||||||
|
- Valor de respuesta (según tipo de pregunta)
|
||||||
|
- Observación
|
||||||
|
- Marcador de señalamiento
|
||||||
|
- Motivo del cambio (obligatorio)
|
||||||
|
- Validación: requiere explicar el motivo del cambio
|
||||||
|
|
||||||
|
2. **Modal de Historial de Cambios**
|
||||||
|
- Botón "📜 Ver Historial de Cambios" en el footer del modal de inspección
|
||||||
|
- Lista cronológica de todos los cambios (más reciente primero)
|
||||||
|
- Para cada cambio muestra:
|
||||||
|
- Quién lo hizo (nombre del usuario)
|
||||||
|
- Cuándo (fecha y hora)
|
||||||
|
- Qué acción realizó
|
||||||
|
- Qué campo modificó
|
||||||
|
- Valor anterior vs valor nuevo (visual con colores)
|
||||||
|
- Motivo del cambio
|
||||||
|
- Iconos visuales según tipo de acción (➕✏️🗑️🔄)
|
||||||
|
|
||||||
|
3. **Restricciones de Seguridad**
|
||||||
|
- Solo administradores pueden editar respuestas
|
||||||
|
- Solo administradores pueden ver el historial
|
||||||
|
- Solo inspecciones completadas pueden editarse
|
||||||
|
- Cada cambio requiere justificación obligatoria
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Instrucciones de Uso
|
||||||
|
|
||||||
|
### **Para Administradores**
|
||||||
|
|
||||||
|
#### 1. Editar una Respuesta
|
||||||
|
|
||||||
|
1. Abre el detalle de una inspección completada
|
||||||
|
2. Busca la respuesta que quieres modificar
|
||||||
|
3. Haz clic en el botón "✏️ Editar" junto a la respuesta
|
||||||
|
4. Modifica los campos necesarios:
|
||||||
|
- **Estado**: Cambia entre OK, Advertencia, Crítico o N/A
|
||||||
|
- **Valor de respuesta**: Solo si la pregunta no es pass/fail
|
||||||
|
- **Observación**: Agrega o modifica comentarios
|
||||||
|
- **Señalado**: Marca o desmarca el flag de atención
|
||||||
|
5. **Importante**: Escribe el motivo del cambio en el campo "Motivo del cambio"
|
||||||
|
6. Haz clic en "Guardar Cambios"
|
||||||
|
7. El sistema:
|
||||||
|
- Actualiza la respuesta
|
||||||
|
- Recalcula los puntos automáticamente
|
||||||
|
- Registra cada cambio en el log de auditoría
|
||||||
|
- Recarga la inspección con los datos actualizados
|
||||||
|
|
||||||
|
**Nota**: No puedes guardar sin escribir un motivo del cambio.
|
||||||
|
|
||||||
|
#### 2. Ver Historial de Cambios
|
||||||
|
|
||||||
|
1. Abre el detalle de cualquier inspección
|
||||||
|
2. En el footer, haz clic en "📜 Ver Historial de Cambios"
|
||||||
|
3. Se abrirá un modal con la bitácora completa
|
||||||
|
4. Revisa:
|
||||||
|
- Todos los cambios realizados por administradores
|
||||||
|
- Orden cronológico (más recientes primero)
|
||||||
|
- Detalles completos de cada modificación
|
||||||
|
- Quién, cuándo, qué y por qué
|
||||||
|
|
||||||
|
#### 3. Tipos de Cambios Registrados
|
||||||
|
|
||||||
|
El sistema registra automáticamente:
|
||||||
|
- **answer_value**: Cambio en la respuesta
|
||||||
|
- **status**: Cambio en el estado (OK/Advertencia/Crítico/N/A)
|
||||||
|
- **comment**: Cambio en las observaciones
|
||||||
|
- **is_flagged**: Marcado o desmarcado de señalamiento
|
||||||
|
- **points_earned**: Recálculo automático de puntos
|
||||||
|
|
||||||
|
### **Para Mecánicos**
|
||||||
|
|
||||||
|
- No pueden editar inspecciones completadas
|
||||||
|
- No pueden ver el historial de cambios
|
||||||
|
- Solo ven el estado final de las respuestas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ Migración de Base de Datos
|
||||||
|
|
||||||
|
### **Ejecutar SQL**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Usando psql
|
||||||
|
psql -U tu_usuario -d tu_database -f migrations/add_inspection_audit_log.sql
|
||||||
|
|
||||||
|
# O directamente
|
||||||
|
psql -U tu_usuario -d tu_database
|
||||||
|
```
|
||||||
|
|
||||||
|
Luego ejecuta:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Crear tabla de auditoría
|
||||||
|
CREATE TABLE IF NOT EXISTS inspection_audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
inspection_id INTEGER NOT NULL REFERENCES inspections(id) ON DELETE CASCADE,
|
||||||
|
answer_id INTEGER REFERENCES answers(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
field_name VARCHAR(100),
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
comment TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Crear índices
|
||||||
|
CREATE INDEX idx_audit_log_inspection ON inspection_audit_log(inspection_id);
|
||||||
|
CREATE INDEX idx_audit_log_answer ON inspection_audit_log(answer_id);
|
||||||
|
CREATE INDEX idx_audit_log_user ON inspection_audit_log(user_id);
|
||||||
|
CREATE INDEX idx_audit_log_created_at ON inspection_audit_log(created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Reiniciar Backend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Si usas Docker
|
||||||
|
docker-compose restart backend
|
||||||
|
|
||||||
|
# Si corres directamente
|
||||||
|
# Ctrl+C y volver a ejecutar
|
||||||
|
python -m uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Ejemplos de Uso
|
||||||
|
|
||||||
|
### Ejemplo 1: Corregir Estado de Respuesta
|
||||||
|
|
||||||
|
**Escenario**: Un mecánico marcó "Crítico" por error cuando debía ser "Advertencia"
|
||||||
|
|
||||||
|
**Pasos**:
|
||||||
|
1. Admin abre la inspección
|
||||||
|
2. Encuentra la respuesta con estado "Crítico"
|
||||||
|
3. Clic en "✏️ Editar"
|
||||||
|
4. Cambia Estado a "Advertencia"
|
||||||
|
5. En "Motivo del cambio": "Error del mecánico, no era crítico sino advertencia menor"
|
||||||
|
6. Guarda cambios
|
||||||
|
7. El sistema:
|
||||||
|
- Actualiza el status de "critical" a "warning"
|
||||||
|
- Recalcula puntos (de 0 a 50% del total)
|
||||||
|
- Registra: field_name="status", old_value="critical", new_value="warning"
|
||||||
|
- Registra: field_name="points_earned", old_value="0", new_value="5"
|
||||||
|
|
||||||
|
**Resultado en Auditoría**:
|
||||||
|
```
|
||||||
|
✏️ Juan Pérez (Admin) • 25 de noviembre de 2025, 14:30
|
||||||
|
Acción: updated en answer (Respuesta #45)
|
||||||
|
|
||||||
|
Campo modificado: status
|
||||||
|
Valor anterior: critical
|
||||||
|
Valor nuevo: warning
|
||||||
|
|
||||||
|
Campo modificado: points_earned
|
||||||
|
Valor anterior: 0
|
||||||
|
Valor nuevo: 5
|
||||||
|
|
||||||
|
Motivo: Error del mecánico, no era crítico sino advertencia menor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ejemplo 2: Agregar Observación Faltante
|
||||||
|
|
||||||
|
**Escenario**: El mecánico no dejó observaciones en un item señalado
|
||||||
|
|
||||||
|
**Pasos**:
|
||||||
|
1. Admin edita la respuesta
|
||||||
|
2. Agrega en "Observación": "Necesita cambio de aceite urgente"
|
||||||
|
3. Mantiene el señalamiento activado
|
||||||
|
4. Motivo: "Agregando observación faltante para clarity"
|
||||||
|
5. Guarda
|
||||||
|
|
||||||
|
**Resultado**: Se registra el cambio de observación de vacío a texto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ejemplo 3: Revisión de Historial
|
||||||
|
|
||||||
|
**Escenario**: Auditoría mensual de cambios en inspecciones
|
||||||
|
|
||||||
|
**Pasos**:
|
||||||
|
1. Admin abre inspección
|
||||||
|
2. Clic en "📜 Ver Historial de Cambios"
|
||||||
|
3. Revisa todos los cambios del mes
|
||||||
|
4. Verifica justificaciones
|
||||||
|
5. Identifica patrones de errores comunes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Base de Datos
|
||||||
|
|
||||||
|
### Estructura de `inspection_audit_log`
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | SERIAL | ID único del registro |
|
||||||
|
| inspection_id | INTEGER | ID de la inspección modificada |
|
||||||
|
| answer_id | INTEGER | ID de la respuesta modificada (nullable) |
|
||||||
|
| user_id | INTEGER | ID del usuario que hizo el cambio |
|
||||||
|
| action | VARCHAR(50) | Tipo de acción (created, updated, deleted, status_changed) |
|
||||||
|
| entity_type | VARCHAR(50) | Tipo de entidad (inspection, answer) |
|
||||||
|
| field_name | VARCHAR(100) | Nombre del campo modificado |
|
||||||
|
| old_value | TEXT | Valor anterior |
|
||||||
|
| new_value | TEXT | Valor nuevo |
|
||||||
|
| comment | TEXT | Motivo del cambio |
|
||||||
|
| created_at | TIMESTAMP | Fecha y hora del cambio |
|
||||||
|
|
||||||
|
### Consultas Útiles
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Ver todos los cambios de una inspección
|
||||||
|
SELECT u.full_name, ial.action, ial.field_name, ial.old_value, ial.new_value, ial.comment, ial.created_at
|
||||||
|
FROM inspection_audit_log ial
|
||||||
|
JOIN users u ON ial.user_id = u.id
|
||||||
|
WHERE ial.inspection_id = 123
|
||||||
|
ORDER BY ial.created_at DESC;
|
||||||
|
|
||||||
|
-- Ver cambios realizados por un admin específico
|
||||||
|
SELECT i.id as inspection_id, i.vehicle_plate, ial.field_name, ial.comment, ial.created_at
|
||||||
|
FROM inspection_audit_log ial
|
||||||
|
JOIN inspections i ON ial.inspection_id = i.id
|
||||||
|
WHERE ial.user_id = 5
|
||||||
|
ORDER BY ial.created_at DESC;
|
||||||
|
|
||||||
|
-- Contar cambios por tipo
|
||||||
|
SELECT action, COUNT(*) as total
|
||||||
|
FROM inspection_audit_log
|
||||||
|
GROUP BY action
|
||||||
|
ORDER BY total DESC;
|
||||||
|
|
||||||
|
-- Ver respuestas más editadas
|
||||||
|
SELECT answer_id, COUNT(*) as ediciones
|
||||||
|
FROM inspection_audit_log
|
||||||
|
WHERE answer_id IS NOT NULL
|
||||||
|
GROUP BY answer_id
|
||||||
|
ORDER BY ediciones DESC
|
||||||
|
LIMIT 10;
|
||||||
|
|
||||||
|
-- Cambios en los últimos 7 días
|
||||||
|
SELECT i.id, i.vehicle_plate, u.full_name, ial.field_name, ial.created_at
|
||||||
|
FROM inspection_audit_log ial
|
||||||
|
JOIN inspections i ON ial.inspection_id = i.id
|
||||||
|
JOIN users u ON ial.user_id = u.id
|
||||||
|
WHERE ial.created_at >= NOW() - INTERVAL '7 days'
|
||||||
|
ORDER BY ial.created_at DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Notas Importantes
|
||||||
|
|
||||||
|
1. **Solo Admins Pueden Editar**
|
||||||
|
- Los mecánicos NO pueden editar inspecciones completadas
|
||||||
|
- Solo usuarios con rol `admin` tienen acceso
|
||||||
|
|
||||||
|
2. **Solo Inspecciones Completadas**
|
||||||
|
- No se pueden editar inspecciones en estado "draft"
|
||||||
|
- El botón de editar solo aparece en inspecciones completadas
|
||||||
|
|
||||||
|
3. **Motivo Obligatorio**
|
||||||
|
- Cada cambio DEBE tener una justificación
|
||||||
|
- El campo "Motivo del cambio" es obligatorio
|
||||||
|
- No se puede guardar sin completarlo
|
||||||
|
|
||||||
|
4. **Recalculo Automático**
|
||||||
|
- Al cambiar el status, los puntos se recalculan automáticamente
|
||||||
|
- OK = 100% de puntos
|
||||||
|
- Warning = 50% de puntos
|
||||||
|
- Critical/NA = 0% de puntos
|
||||||
|
|
||||||
|
5. **Registro Completo**
|
||||||
|
- Cada campo modificado genera un registro separado
|
||||||
|
- Se guarda el valor anterior y el nuevo
|
||||||
|
- Se registra quién y cuándo hizo el cambio
|
||||||
|
- No se pueden borrar registros de auditoría
|
||||||
|
|
||||||
|
6. **Cascada en Borrado**
|
||||||
|
- Si se borra una inspección, se borran sus logs
|
||||||
|
- Si se borra una respuesta, se borran sus logs
|
||||||
|
- Los logs del usuario permanecen aunque se borre el usuario
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Problema: "No puedo editar una respuesta"
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
1. Verificar que eres admin: `SELECT role FROM users WHERE id = X;`
|
||||||
|
2. Verificar que la inspección está completada: `SELECT status FROM inspections WHERE id = Y;`
|
||||||
|
3. Verificar que el botón "✏️ Editar" aparece
|
||||||
|
4. Revisar consola del navegador para errores
|
||||||
|
|
||||||
|
### Problema: "Error al guardar cambios"
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
1. Verificar que completaste el campo "Motivo del cambio"
|
||||||
|
2. Verificar token de autenticación válido
|
||||||
|
3. Revisar logs del backend para errores específicos
|
||||||
|
4. Verificar que la tabla `inspection_audit_log` existe
|
||||||
|
|
||||||
|
### Problema: "No veo el historial de cambios"
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
1. Verificar que eres admin
|
||||||
|
2. Verificar que hay cambios registrados: `SELECT * FROM inspection_audit_log WHERE inspection_id = X;`
|
||||||
|
3. Limpiar caché del navegador (Ctrl+Shift+R)
|
||||||
|
4. Revisar consola del navegador para errores de API
|
||||||
|
|
||||||
|
### Problema: "Los puntos no se recalculan correctamente"
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
1. Verificar la lógica en el backend (main.py, admin_edit_answer)
|
||||||
|
2. Revisar que la pregunta tiene `points` configurados
|
||||||
|
3. Verificar logs de auditoría para ver si se registró el cambio de puntos
|
||||||
|
4. Recalcular manualmente si es necesario
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Resumen
|
||||||
|
|
||||||
|
✅ **Backend**: Sistema completo de auditoría con registro automático
|
||||||
|
✅ **Frontend**: Edición inline + modal de historial
|
||||||
|
✅ **Base de Datos**: Tabla de auditoría con índices optimizados
|
||||||
|
✅ **Seguridad**: Solo admins, motivo obligatorio, registro inmutable
|
||||||
|
✅ **Documentación**: Completa con ejemplos y troubleshooting
|
||||||
|
|
||||||
|
El sistema está listo para usar después de ejecutar la migración SQL y reiniciar el backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Beneficios
|
||||||
|
|
||||||
|
1. **Trazabilidad Completa**: Saber quién cambió qué y cuándo
|
||||||
|
2. **Auditoría**: Cumplimiento de normas de calidad y transparencia
|
||||||
|
3. **Corrección de Errores**: Admins pueden corregir errores sin perder datos
|
||||||
|
4. **Accountability**: Cada cambio requiere justificación documentada
|
||||||
|
5. **Historial Inmutable**: Los registros no se pueden borrar ni modificar
|
||||||
|
6. **Reportes**: Base de datos lista para generar reportes de cambios
|
||||||
283
PERMISOS_CHECKLIST.md
Normal file
283
PERMISOS_CHECKLIST.md
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
# Sistema de Permisos por Mecánico - Checklists
|
||||||
|
|
||||||
|
## ✅ Implementación Completa
|
||||||
|
|
||||||
|
Se ha implementado un sistema completo de permisos que permite controlar qué mecánicos pueden usar cada checklist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Características Implementadas
|
||||||
|
|
||||||
|
### **Backend**
|
||||||
|
|
||||||
|
1. **Nueva Tabla de Permisos**
|
||||||
|
- Tabla `checklist_permissions` con relación many-to-many
|
||||||
|
- Constraint UNIQUE para evitar duplicados
|
||||||
|
- Índices optimizados para consultas rápidas
|
||||||
|
|
||||||
|
2. **Lógica de Acceso**
|
||||||
|
- **Acceso Global**: Si un checklist NO tiene permisos registrados, todos los mecánicos pueden usarlo
|
||||||
|
- **Acceso Restringido**: Si tiene permisos, solo esos mecánicos específicos pueden verlo
|
||||||
|
- **Admins**: Siempre ven todos los checklists
|
||||||
|
|
||||||
|
3. **Endpoints Actualizados**
|
||||||
|
- `GET /api/checklists`: Filtra automáticamente por permisos del mecánico
|
||||||
|
- `POST /api/checklists`: Guarda permisos al crear
|
||||||
|
- `PUT /api/checklists/{id}`: Actualiza permisos al editar
|
||||||
|
- Incluye `allowed_mechanics` en las respuestas
|
||||||
|
|
||||||
|
### **Frontend**
|
||||||
|
|
||||||
|
1. **Creación de Checklists**
|
||||||
|
- Selector de mecánicos con checkboxes
|
||||||
|
- Opción "Acceso Global" (no seleccionar ninguno)
|
||||||
|
- Interfaz clara con íconos 🔐 y 🌍
|
||||||
|
|
||||||
|
2. **Visualización**
|
||||||
|
- Badge verde "🌍 Acceso Global" para checklists sin restricciones
|
||||||
|
- Badge índigo "🔐 Restringido - X mecánicos" para checklists restringidos
|
||||||
|
- Solo visible para administradores
|
||||||
|
|
||||||
|
3. **Edición de Permisos**
|
||||||
|
- Botón "🔐 Permisos" en cada checklist (solo admins)
|
||||||
|
- Modal dedicado para editar permisos
|
||||||
|
- Cambios se aplican inmediatamente
|
||||||
|
|
||||||
|
4. **Mensajes Mejorados**
|
||||||
|
- Mensaje específico para mecánicos sin checklists disponibles
|
||||||
|
- Instrucciones claras para contactar al administrador
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Instrucciones de Uso
|
||||||
|
|
||||||
|
### **Para Administradores**
|
||||||
|
|
||||||
|
#### 1. Crear Checklist con Permisos
|
||||||
|
|
||||||
|
1. Ve a la pestaña "Checklists"
|
||||||
|
2. Haz clic en "+ Crear Checklist"
|
||||||
|
3. Completa los datos del checklist
|
||||||
|
4. En la sección "🔐 Mecánicos Autorizados":
|
||||||
|
- **Para acceso global**: No selecciones ningún mecánico (deja todo sin marcar)
|
||||||
|
- **Para acceso restringido**: Marca los mecánicos que tendrán acceso
|
||||||
|
5. Haz clic en "Crear Checklist"
|
||||||
|
|
||||||
|
#### 2. Editar Permisos de Checklist Existente
|
||||||
|
|
||||||
|
1. Ve a la pestaña "Checklists"
|
||||||
|
2. Busca el checklist que quieres modificar
|
||||||
|
3. Haz clic en el botón "🔐 Permisos"
|
||||||
|
4. Modifica la selección de mecánicos:
|
||||||
|
- Marca "🌍 Todos los mecánicos" para acceso global
|
||||||
|
- O selecciona mecánicos específicos
|
||||||
|
5. Haz clic en "Guardar Permisos"
|
||||||
|
|
||||||
|
**Nota**: Los cambios son inmediatos. Los mecánicos que pierdan acceso dejarán de ver el checklist instantáneamente.
|
||||||
|
|
||||||
|
#### 3. Ver Estado de Permisos
|
||||||
|
|
||||||
|
Cada tarjeta de checklist muestra:
|
||||||
|
- **🌍 Acceso Global - Todos los mecánicos**: Sin restricciones
|
||||||
|
- **🔐 Restringido - X mecánicos**: Solo esos mecánicos tienen acceso
|
||||||
|
|
||||||
|
### **Para Mecánicos**
|
||||||
|
|
||||||
|
- Solo verás los checklists donde tienes permiso
|
||||||
|
- Si no ves ningún checklist, contacta al administrador
|
||||||
|
- No puedes modificar permisos (solo el admin puede hacerlo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ Migración de Base de Datos
|
||||||
|
|
||||||
|
### **Ejecutar SQL**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Usando psql
|
||||||
|
psql -U tu_usuario -d tu_database -f migrations/add_checklist_permissions.sql
|
||||||
|
|
||||||
|
# O directamente
|
||||||
|
psql -U tu_usuario -d tu_database
|
||||||
|
```
|
||||||
|
|
||||||
|
Luego ejecuta:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Crear tabla de permisos checklist-mecánico
|
||||||
|
CREATE TABLE IF NOT EXISTS checklist_permissions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE,
|
||||||
|
mechanic_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(checklist_id, mechanic_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Crear índices
|
||||||
|
CREATE INDEX idx_checklist_permissions_checklist ON checklist_permissions(checklist_id);
|
||||||
|
CREATE INDEX idx_checklist_permissions_mechanic ON checklist_permissions(mechanic_id);
|
||||||
|
|
||||||
|
-- Comentarios
|
||||||
|
COMMENT ON TABLE checklist_permissions IS 'Control de acceso de mecánicos a checklists. Si no hay registros para un checklist, todos los mecánicos tienen acceso.';
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Reiniciar Backend**
|
||||||
|
|
||||||
|
Después de ejecutar el SQL, reinicia el backend para que los cambios en los modelos tomen efecto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Si usas Docker
|
||||||
|
docker-compose restart backend
|
||||||
|
|
||||||
|
# Si corres directamente
|
||||||
|
# Ctrl+C y volver a ejecutar
|
||||||
|
python -m uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Ejemplos de Uso
|
||||||
|
|
||||||
|
### Ejemplo 1: Checklist para Todos los Mecánicos
|
||||||
|
|
||||||
|
**Escenario**: Checklist de "Revisión Básica" que todos pueden usar
|
||||||
|
|
||||||
|
**Configuración**:
|
||||||
|
- Al crear/editar: No seleccionar ningún mecánico
|
||||||
|
- El sistema muestra: "🌍 Acceso Global - Todos los mecánicos"
|
||||||
|
|
||||||
|
**Resultado**: Todos los mecánicos ven este checklist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ejemplo 2: Checklist Especializado
|
||||||
|
|
||||||
|
**Escenario**: Checklist de "Mantenimiento Eléctrico" solo para mecánicos certificados
|
||||||
|
|
||||||
|
**Configuración**:
|
||||||
|
1. Al crear/editar: Seleccionar solo mecánicos con certificación eléctrica
|
||||||
|
2. El sistema muestra: "🔐 Restringido - 3 mecánicos"
|
||||||
|
|
||||||
|
**Resultado**: Solo esos 3 mecánicos ven este checklist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ejemplo 3: Cambio de Permisos
|
||||||
|
|
||||||
|
**Escenario**: Un mecánico nuevo se certifica en electricidad
|
||||||
|
|
||||||
|
**Pasos**:
|
||||||
|
1. Admin hace clic en "🔐 Permisos" del checklist "Mantenimiento Eléctrico"
|
||||||
|
2. Marca al nuevo mecánico en la lista
|
||||||
|
3. Guarda cambios
|
||||||
|
4. El mecánico inmediatamente ve el checklist en su lista
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Base de Datos
|
||||||
|
|
||||||
|
### Estructura de `checklist_permissions`
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | SERIAL | ID único de la relación |
|
||||||
|
| checklist_id | INTEGER | ID del checklist |
|
||||||
|
| mechanic_id | INTEGER | ID del mecánico autorizado |
|
||||||
|
| created_at | TIMESTAMP | Fecha de creación del permiso |
|
||||||
|
|
||||||
|
### Consultas Útiles
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Ver permisos de un checklist específico
|
||||||
|
SELECT u.full_name, u.email
|
||||||
|
FROM checklist_permissions cp
|
||||||
|
JOIN users u ON cp.mechanic_id = u.id
|
||||||
|
WHERE cp.checklist_id = 1;
|
||||||
|
|
||||||
|
-- Ver qué checklists puede usar un mecánico
|
||||||
|
SELECT c.name, c.description
|
||||||
|
FROM checklist_permissions cp
|
||||||
|
JOIN checklists c ON cp.checklist_id = c.id
|
||||||
|
WHERE cp.mechanic_id = 5;
|
||||||
|
|
||||||
|
-- Checklists sin restricciones (acceso global)
|
||||||
|
SELECT c.id, c.name
|
||||||
|
FROM checklists c
|
||||||
|
LEFT JOIN checklist_permissions cp ON c.id = cp.checklist_id
|
||||||
|
WHERE cp.id IS NULL;
|
||||||
|
|
||||||
|
-- Dar acceso a un mecánico a un checklist
|
||||||
|
INSERT INTO checklist_permissions (checklist_id, mechanic_id)
|
||||||
|
VALUES (1, 5)
|
||||||
|
ON CONFLICT (checklist_id, mechanic_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Quitar acceso
|
||||||
|
DELETE FROM checklist_permissions
|
||||||
|
WHERE checklist_id = 1 AND mechanic_id = 5;
|
||||||
|
|
||||||
|
-- Convertir checklist a acceso global (borrar todos los permisos)
|
||||||
|
DELETE FROM checklist_permissions WHERE checklist_id = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Notas Importantes
|
||||||
|
|
||||||
|
1. **Permisos Vacíos = Acceso Global**
|
||||||
|
- Si un checklist NO tiene registros en `checklist_permissions`, TODOS los mecánicos pueden usarlo
|
||||||
|
- Es el comportamiento por defecto
|
||||||
|
|
||||||
|
2. **Los Admins Siempre Ven Todo**
|
||||||
|
- Los usuarios con rol `admin` ven todos los checklists sin importar los permisos
|
||||||
|
- Útil para gestión y supervisión
|
||||||
|
|
||||||
|
3. **Cambios Inmediatos**
|
||||||
|
- Al editar permisos, los cambios se aplican instantáneamente
|
||||||
|
- No requiere logout/login
|
||||||
|
|
||||||
|
4. **Cascada en Borrado**
|
||||||
|
- Si borras un checklist, sus permisos se borran automáticamente
|
||||||
|
- Si borras un mecánico, sus permisos se borran automáticamente
|
||||||
|
|
||||||
|
5. **Inspecciones Existentes**
|
||||||
|
- Las inspecciones ya creadas NO se ven afectadas por cambios de permisos
|
||||||
|
- Solo afecta la creación de nuevas inspecciones
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Problema: "Mecánico no ve ningún checklist"
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
1. Verificar que el mecánico esté activo: `SELECT is_active FROM users WHERE id = X;`
|
||||||
|
2. Verificar permisos: `SELECT * FROM checklist_permissions WHERE mechanic_id = X;`
|
||||||
|
3. Verificar si hay checklists con acceso global (sin permisos)
|
||||||
|
4. Verificar rol del usuario: debe ser `mechanic` o `mecanico`
|
||||||
|
|
||||||
|
### Problema: "Error al crear/editar permisos"
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
1. Verificar que la tabla `checklist_permissions` existe
|
||||||
|
2. Verificar que los IDs de mecánicos son válidos
|
||||||
|
3. Revisar logs del backend para errores específicos
|
||||||
|
4. Verificar que el usuario es admin
|
||||||
|
|
||||||
|
### Problema: "Los permisos no se aplican"
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
1. Hacer logout y login nuevamente
|
||||||
|
2. Verificar que el backend se reinició después de la migración
|
||||||
|
3. Limpiar caché del navegador (Ctrl+Shift+R)
|
||||||
|
4. Verificar en la base de datos que los permisos se guardaron correctamente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Resumen
|
||||||
|
|
||||||
|
✅ **Backend**: Filtrado automático por permisos
|
||||||
|
✅ **Frontend**: Interfaz completa para gestionar permisos
|
||||||
|
✅ **Base de Datos**: Migración lista para ejecutar
|
||||||
|
✅ **Documentación**: Completa con ejemplos
|
||||||
|
|
||||||
|
El sistema está listo para usar después de ejecutar la migración SQL y reiniciar el backend.
|
||||||
48
README.md
48
README.md
@@ -360,6 +360,54 @@ UPDATE checklists SET max_score = (
|
|||||||
|
|
||||||
MIT License - Uso libre para proyectos comerciales y personales
|
MIT License - Uso libre para proyectos comerciales y personales
|
||||||
|
|
||||||
|
## 📝 Control de Versiones
|
||||||
|
|
||||||
|
### Instrucciones para commits de Git
|
||||||
|
|
||||||
|
**IMPORTANTE**: Siempre incluir la versión actualizada en los mensajes de commit.
|
||||||
|
|
||||||
|
Formato recomendado:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "tipo: descripción del cambio
|
||||||
|
|
||||||
|
- Detalle 1
|
||||||
|
- Detalle 2
|
||||||
|
- Frontend vX.X.XX / Backend vX.X.XX"
|
||||||
|
```
|
||||||
|
|
||||||
|
Tipos de commit:
|
||||||
|
- `feat`: Nueva funcionalidad
|
||||||
|
- `fix`: Corrección de bugs
|
||||||
|
- `refactor`: Refactorización de código
|
||||||
|
- `style`: Cambios de formato/estilo
|
||||||
|
- `docs`: Actualización de documentación
|
||||||
|
- `perf`: Mejoras de rendimiento
|
||||||
|
- `test`: Añadir o actualizar tests
|
||||||
|
|
||||||
|
**Ejemplo real**:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: Add pagination (10 items/page) to all main tabs
|
||||||
|
|
||||||
|
- Pagination for Inspections, Checklists, and Reports
|
||||||
|
- Auto-reset on filter changes
|
||||||
|
- Smart page navigation with ellipsis
|
||||||
|
- Result counters showing X-Y of Z items
|
||||||
|
- Frontend v1.0.64"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versionado
|
||||||
|
|
||||||
|
Seguir **Semantic Versioning** (MAJOR.MINOR.PATCH):
|
||||||
|
- **MAJOR**: Cambios incompatibles en la API
|
||||||
|
- **MINOR**: Nueva funcionalidad compatible con versiones anteriores
|
||||||
|
- **PATCH**: Correcciones de bugs
|
||||||
|
|
||||||
|
Ubicación de versiones:
|
||||||
|
- Frontend: `frontend/package.json` → `"version": "X.X.XX"`
|
||||||
|
- Backend: `backend/app/main.py` → `version="X.X.XX"` en FastAPI app
|
||||||
|
|
||||||
## 🆘 Soporte
|
## 🆘 Soporte
|
||||||
|
|
||||||
Para problemas o preguntas:
|
Para problemas o preguntas:
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import os
|
||||||
|
# Variables de conexión S3/MinIO
|
||||||
|
MINIO_HOST = os.getenv('MINIO_HOST', 'localhost')
|
||||||
|
MINIO_SECURE = os.getenv('MINIO_SECURE', 'false').lower() == 'true'
|
||||||
|
MINIO_PORT = int(os.getenv('MINIO_PORT', '9000'))
|
||||||
|
MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY', 'minioadmin')
|
||||||
|
MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY', 'minioadmin')
|
||||||
|
MINIO_IMAGE_BUCKET = os.getenv('MINIO_IMAGE_BUCKET', 'images')
|
||||||
|
MINIO_PDF_BUCKET = os.getenv('MINIO_PDF_BUCKET', 'pdfs')
|
||||||
|
MINIO_ENDPOINT = f"{'https' if MINIO_SECURE else 'http'}://{MINIO_HOST}:{MINIO_PORT}"
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -15,6 +25,9 @@ class Settings(BaseSettings):
|
|||||||
# Environment
|
# Environment
|
||||||
ENVIRONMENT: str = "development"
|
ENVIRONMENT: str = "development"
|
||||||
|
|
||||||
|
# Notificaciones
|
||||||
|
NOTIFICACION_ENDPOINT: str = ""
|
||||||
|
|
||||||
# CORS - Orígenes permitidos separados por coma
|
# CORS - Orígenes permitidos separados por coma
|
||||||
ALLOWED_ORIGINS: str = "http://localhost:3000,http://localhost:5173"
|
ALLOWED_ORIGINS: str = "http://localhost:3000,http://localhost:5173"
|
||||||
|
|
||||||
|
|||||||
2924
backend/app/main.py
2924
backend/app/main.py
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ class User(Base):
|
|||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
role = Column(String(20), nullable=False) # admin, mechanic, asesor
|
role = Column(String(20), nullable=False) # admin, mechanic, asesor
|
||||||
full_name = Column(String(100))
|
full_name = Column(String(100))
|
||||||
|
employee_code = Column(String(50)) # Nro Operario - código de otro sistema
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ class Checklist(Base):
|
|||||||
scoring_enabled = Column(Boolean, default=True)
|
scoring_enabled = Column(Boolean, default=True)
|
||||||
max_score = Column(Integer, default=0)
|
max_score = Column(Integer, default=0)
|
||||||
logo_url = Column(String(500))
|
logo_url = Column(String(500))
|
||||||
|
generate_pdf = Column(Boolean, default=True) # Controla si se genera PDF al completar
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_by = Column(Integer, ForeignKey("users.id"))
|
created_by = Column(Integer, ForeignKey("users.id"))
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -55,6 +57,7 @@ class Checklist(Base):
|
|||||||
creator = relationship("User", back_populates="checklists_created")
|
creator = relationship("User", back_populates="checklists_created")
|
||||||
questions = relationship("Question", back_populates="checklist", cascade="all, delete-orphan")
|
questions = relationship("Question", back_populates="checklist", cascade="all, delete-orphan")
|
||||||
inspections = relationship("Inspection", back_populates="checklist")
|
inspections = relationship("Inspection", back_populates="checklist")
|
||||||
|
permissions = relationship("ChecklistPermission", back_populates="checklist", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class Question(Base):
|
class Question(Base):
|
||||||
@@ -64,19 +67,29 @@ class Question(Base):
|
|||||||
checklist_id = Column(Integer, ForeignKey("checklists.id"), nullable=False)
|
checklist_id = Column(Integer, ForeignKey("checklists.id"), nullable=False)
|
||||||
section = Column(String(100)) # Sistema eléctrico, Frenos, etc
|
section = Column(String(100)) # Sistema eléctrico, Frenos, etc
|
||||||
text = Column(Text, nullable=False)
|
text = Column(Text, nullable=False)
|
||||||
type = Column(String(30), nullable=False) # pass_fail, good_bad, text, etc
|
type = Column(String(30), nullable=False) # boolean, single_choice, multiple_choice, scale, text, number, date, time
|
||||||
points = Column(Integer, default=1)
|
points = Column(Integer, default=1)
|
||||||
options = Column(JSON) # Para multiple choice
|
options = Column(JSON) # Configuración flexible según tipo de pregunta
|
||||||
order = Column(Integer, default=0)
|
order = Column(Integer, default=0)
|
||||||
allow_photos = Column(Boolean, default=True)
|
allow_photos = Column(Boolean, default=True) # DEPRECATED: usar photo_requirement
|
||||||
|
photo_requirement = Column(String(20), default='optional') # none, optional, required
|
||||||
max_photos = Column(Integer, default=3)
|
max_photos = Column(Integer, default=3)
|
||||||
requires_comment_on_fail = Column(Boolean, default=False)
|
requires_comment_on_fail = Column(Boolean, default=False)
|
||||||
|
send_notification = Column(Boolean, default=False)
|
||||||
|
|
||||||
# Conditional logic
|
# Conditional logic - Subpreguntas anidadas hasta 5 niveles
|
||||||
parent_question_id = Column(Integer, ForeignKey("questions.id"), nullable=True)
|
parent_question_id = Column(Integer, ForeignKey("questions.id"), nullable=True)
|
||||||
show_if_answer = Column(String(50), nullable=True) # Valor que dispara esta pregunta
|
show_if_answer = Column(String(50), nullable=True) # Valor que dispara esta pregunta
|
||||||
|
depth_level = Column(Integer, default=0) # 0=principal, 1-5=subpreguntas anidadas
|
||||||
|
|
||||||
|
# AI Analysis
|
||||||
|
ai_prompt = Column(Text, nullable=True) # Prompt personalizado para análisis de IA de esta pregunta
|
||||||
|
|
||||||
|
# Soft Delete
|
||||||
|
is_deleted = Column(Boolean, default=False) # Soft delete: mantiene integridad de respuestas históricas
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
checklist = relationship("Checklist", back_populates="questions")
|
checklist = relationship("Checklist", back_populates="questions")
|
||||||
@@ -101,7 +114,10 @@ class Inspection(Base):
|
|||||||
vehicle_brand = Column(String(50))
|
vehicle_brand = Column(String(50))
|
||||||
vehicle_model = Column(String(100))
|
vehicle_model = Column(String(100))
|
||||||
vehicle_km = Column(Integer)
|
vehicle_km = Column(Integer)
|
||||||
client_name = Column(String(200))
|
order_number = Column(String(200)) # Nº de Pedido
|
||||||
|
|
||||||
|
# Datos del mecánico
|
||||||
|
mechanic_employee_code = Column(String(50)) # Código de operario del mecánico
|
||||||
|
|
||||||
# Scoring
|
# Scoring
|
||||||
score = Column(Integer, default=0)
|
score = Column(Integer, default=0)
|
||||||
@@ -110,7 +126,7 @@ class Inspection(Base):
|
|||||||
flagged_items_count = Column(Integer, default=0)
|
flagged_items_count = Column(Integer, default=0)
|
||||||
|
|
||||||
# Estado
|
# Estado
|
||||||
status = Column(String(20), default="draft") # draft, completed, inactive
|
status = Column(String(20), default="incomplete") # incomplete, completed, inactive
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
# Firma
|
# Firma
|
||||||
@@ -123,6 +139,7 @@ class Inspection(Base):
|
|||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
pdf_url = Column(String(500)) # URL del PDF en S3
|
||||||
# Relationships
|
# Relationships
|
||||||
checklist = relationship("Checklist", back_populates="inspections")
|
checklist = relationship("Checklist", back_populates="inspections")
|
||||||
mechanic = relationship("User", back_populates="inspections")
|
mechanic = relationship("User", back_populates="inspections")
|
||||||
@@ -142,6 +159,7 @@ class Answer(Base):
|
|||||||
comment = Column(Text) # Comentarios adicionales
|
comment = Column(Text) # Comentarios adicionales
|
||||||
|
|
||||||
ai_analysis = Column(JSON) # Análisis de IA si aplica
|
ai_analysis = Column(JSON) # Análisis de IA si aplica
|
||||||
|
chat_history = Column(JSON) # Historial de chat con AI Assistant (para tipo ai_assistant)
|
||||||
is_flagged = Column(Boolean, default=False) # Si requiere atención
|
is_flagged = Column(Boolean, default=False) # Si requiere atención
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -176,7 +194,65 @@ class AIConfiguration(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
provider = Column(String(50), nullable=False) # openai, gemini
|
provider = Column(String(50), nullable=False) # openai, gemini
|
||||||
api_key = Column(Text, nullable=False)
|
api_key = Column(Text, nullable=False)
|
||||||
model_name = Column(String(100), nullable=False)
|
model_name = Column(String(100), nullable=True)
|
||||||
|
logo_url = Column(Text, nullable=True) # URL del logo configurable
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ChecklistPermission(Base):
|
||||||
|
"""Tabla intermedia para permisos de checklist por mecánico"""
|
||||||
|
__tablename__ = "checklist_permissions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
checklist_id = Column(Integer, ForeignKey("checklists.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
mechanic_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
checklist = relationship("Checklist", back_populates="permissions")
|
||||||
|
mechanic = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionAuditLog(Base):
|
||||||
|
"""Registro de auditoría para cambios en preguntas de checklists"""
|
||||||
|
__tablename__ = "question_audit_log"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
checklist_id = Column(Integer, ForeignKey("checklists.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
action = Column(String(50), nullable=False) # created, updated, deleted
|
||||||
|
field_name = Column(String(100), nullable=True) # Campo modificado
|
||||||
|
old_value = Column(Text, nullable=True) # Valor anterior
|
||||||
|
new_value = Column(Text, nullable=True) # Valor nuevo
|
||||||
|
comment = Column(Text, nullable=True) # Comentario del cambio
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
question = relationship("Question")
|
||||||
|
checklist = relationship("Checklist")
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class InspectionAuditLog(Base):
|
||||||
|
"""Registro de auditoría para cambios en inspecciones y respuestas"""
|
||||||
|
__tablename__ = "inspection_audit_log"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
inspection_id = Column(Integer, ForeignKey("inspections.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
answer_id = Column(Integer, ForeignKey("answers.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
action = Column(String(50), nullable=False) # created, updated, deleted, status_changed
|
||||||
|
entity_type = Column(String(50), nullable=False) # inspection, answer
|
||||||
|
field_name = Column(String(100), nullable=True) # Campo modificado
|
||||||
|
old_value = Column(Text, nullable=True) # Valor anterior
|
||||||
|
new_value = Column(Text, nullable=True) # Valor nuevo
|
||||||
|
comment = Column(Text, nullable=True) # Comentario del cambio
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
inspection = relationship("Inspection")
|
||||||
|
answer = relationship("Answer")
|
||||||
|
user = relationship("User")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class UserBase(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
|
employee_code: Optional[str] = None # Nro Operario - código de otro sistema
|
||||||
role: str = "mechanic"
|
role: str = "mechanic"
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
@@ -16,6 +17,7 @@ class UserUpdate(BaseModel):
|
|||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
|
employee_code: Optional[str] = None
|
||||||
role: Optional[str] = None
|
role: Optional[str] = None
|
||||||
|
|
||||||
class UserPasswordUpdate(BaseModel):
|
class UserPasswordUpdate(BaseModel):
|
||||||
@@ -31,6 +33,7 @@ class UserLogin(BaseModel):
|
|||||||
|
|
||||||
class User(UserBase):
|
class User(UserBase):
|
||||||
id: int
|
id: int
|
||||||
|
employee_code: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
@@ -68,37 +71,66 @@ class ChecklistBase(BaseModel):
|
|||||||
ai_mode: str = "off"
|
ai_mode: str = "off"
|
||||||
scoring_enabled: bool = True
|
scoring_enabled: bool = True
|
||||||
logo_url: Optional[str] = None
|
logo_url: Optional[str] = None
|
||||||
|
generate_pdf: bool = True
|
||||||
|
|
||||||
class ChecklistCreate(ChecklistBase):
|
class ChecklistCreate(ChecklistBase):
|
||||||
pass
|
mechanic_ids: Optional[List[int]] = [] # IDs de mecánicos autorizados
|
||||||
|
|
||||||
class ChecklistUpdate(ChecklistBase):
|
class ChecklistUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
ai_mode: Optional[str] = None
|
||||||
|
scoring_enabled: Optional[bool] = None
|
||||||
|
logo_url: Optional[str] = None
|
||||||
|
generate_pdf: Optional[bool] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
mechanic_ids: Optional[List[int]] = None # IDs de mecánicos autorizados
|
||||||
|
|
||||||
class Checklist(ChecklistBase):
|
class Checklist(ChecklistBase):
|
||||||
id: int
|
id: int
|
||||||
max_score: int
|
max_score: int
|
||||||
|
generate_pdf: bool
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
allowed_mechanics: Optional[List[int]] = [] # IDs de mecánicos permitidos
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Question Schemas
|
# Question Schemas
|
||||||
|
# Tipos de preguntas soportados:
|
||||||
|
# - boolean: Dos opciones personalizables (ej: Sí/No, Pasa/Falla)
|
||||||
|
# - single_choice: Selección única con N opciones
|
||||||
|
# - multiple_choice: Selección múltiple
|
||||||
|
# - scale: Escala numérica (1-5, 1-10, etc.)
|
||||||
|
# - text: Texto libre
|
||||||
|
# - number: Valor numérico
|
||||||
|
# - date: Fecha
|
||||||
|
# - time: Hora
|
||||||
|
|
||||||
class QuestionBase(BaseModel):
|
class QuestionBase(BaseModel):
|
||||||
section: Optional[str] = None
|
section: Optional[str] = None
|
||||||
text: str
|
text: str
|
||||||
type: str
|
type: str # boolean, single_choice, multiple_choice, scale, text, number, date, time
|
||||||
points: int = 1
|
points: int = 1
|
||||||
options: Optional[dict] = None
|
options: Optional[dict] = None # Configuración flexible según tipo
|
||||||
|
# Estructura de options:
|
||||||
|
# Boolean: {"type": "boolean", "choices": [{"value": "yes", "label": "Sí", "points": 1, "status": "ok"}, ...]}
|
||||||
|
# Single/Multiple Choice: {"type": "single_choice", "choices": [{"value": "opt1", "label": "Opción 1", "points": 2}, ...]}
|
||||||
|
# Scale: {"type": "scale", "min": 1, "max": 5, "step": 1, "labels": {"min": "Muy malo", "max": "Excelente"}}
|
||||||
|
# Text: {"type": "text", "multiline": true, "max_length": 500}
|
||||||
order: int = 0
|
order: int = 0
|
||||||
allow_photos: bool = True
|
allow_photos: bool = True # DEPRECATED: mantener por compatibilidad
|
||||||
|
photo_requirement: Optional[str] = 'optional' # none, optional, required
|
||||||
max_photos: int = 3
|
max_photos: int = 3
|
||||||
requires_comment_on_fail: bool = False
|
requires_comment_on_fail: bool = False
|
||||||
|
send_notification: bool = False
|
||||||
parent_question_id: Optional[int] = None
|
parent_question_id: Optional[int] = None
|
||||||
show_if_answer: Optional[str] = None
|
show_if_answer: Optional[str] = None
|
||||||
|
ai_prompt: Optional[str] = None
|
||||||
|
is_deleted: bool = False
|
||||||
|
|
||||||
class QuestionCreate(QuestionBase):
|
class QuestionCreate(QuestionBase):
|
||||||
checklist_id: int
|
checklist_id: int
|
||||||
@@ -106,15 +138,37 @@ class QuestionCreate(QuestionBase):
|
|||||||
class QuestionUpdate(QuestionBase):
|
class QuestionUpdate(QuestionBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class QuestionReorder(BaseModel):
|
||||||
|
question_id: int
|
||||||
|
new_order: int
|
||||||
|
|
||||||
class Question(QuestionBase):
|
class Question(QuestionBase):
|
||||||
id: int
|
id: int
|
||||||
checklist_id: int
|
checklist_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Question Audit Schemas
|
||||||
|
class QuestionAuditLog(BaseModel):
|
||||||
|
id: int
|
||||||
|
question_id: int
|
||||||
|
checklist_id: int
|
||||||
|
user_id: int
|
||||||
|
action: str
|
||||||
|
field_name: Optional[str] = None
|
||||||
|
old_value: Optional[str] = None
|
||||||
|
new_value: Optional[str] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
user: Optional['User'] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Inspection Schemas
|
# Inspection Schemas
|
||||||
class InspectionBase(BaseModel):
|
class InspectionBase(BaseModel):
|
||||||
@@ -124,7 +178,8 @@ class InspectionBase(BaseModel):
|
|||||||
vehicle_brand: Optional[str] = None
|
vehicle_brand: Optional[str] = None
|
||||||
vehicle_model: Optional[str] = None
|
vehicle_model: Optional[str] = None
|
||||||
vehicle_km: Optional[int] = None
|
vehicle_km: Optional[int] = None
|
||||||
client_name: Optional[str] = None
|
order_number: Optional[str] = None # Nº de Pedido
|
||||||
|
mechanic_employee_code: Optional[str] = None
|
||||||
|
|
||||||
class InspectionCreate(InspectionBase):
|
class InspectionCreate(InspectionBase):
|
||||||
checklist_id: int
|
checklist_id: int
|
||||||
@@ -140,6 +195,7 @@ class Inspection(InspectionBase):
|
|||||||
id: int
|
id: int
|
||||||
checklist_id: int
|
checklist_id: int
|
||||||
mechanic_id: int
|
mechanic_id: int
|
||||||
|
mechanic_employee_code: Optional[str] = None
|
||||||
score: int
|
score: int
|
||||||
max_score: int
|
max_score: int
|
||||||
percentage: float
|
percentage: float
|
||||||
@@ -154,7 +210,7 @@ class Inspection(InspectionBase):
|
|||||||
|
|
||||||
# Answer Schemas
|
# Answer Schemas
|
||||||
class AnswerBase(BaseModel):
|
class AnswerBase(BaseModel):
|
||||||
answer_value: str
|
answer_value: Optional[str] = None # Opcional para permitir guardar solo análisis IA
|
||||||
status: str = "ok"
|
status: str = "ok"
|
||||||
comment: Optional[str] = None
|
comment: Optional[str] = None
|
||||||
is_flagged: bool = False
|
is_flagged: bool = False
|
||||||
@@ -162,6 +218,8 @@ class AnswerBase(BaseModel):
|
|||||||
class AnswerCreate(AnswerBase):
|
class AnswerCreate(AnswerBase):
|
||||||
inspection_id: int
|
inspection_id: int
|
||||||
question_id: int
|
question_id: int
|
||||||
|
ai_analysis: Optional[list] = None # Lista de análisis de IA (soporta múltiples imágenes)
|
||||||
|
chat_history: Optional[list] = None # Historial de chat con AI Assistant
|
||||||
|
|
||||||
class AnswerUpdate(AnswerBase):
|
class AnswerUpdate(AnswerBase):
|
||||||
pass
|
pass
|
||||||
@@ -171,7 +229,8 @@ class Answer(AnswerBase):
|
|||||||
inspection_id: int
|
inspection_id: int
|
||||||
question_id: int
|
question_id: int
|
||||||
points_earned: int
|
points_earned: int
|
||||||
ai_analysis: Optional[dict] = None
|
ai_analysis: Optional[list] = None # Lista de análisis de IA
|
||||||
|
chat_history: Optional[list] = None # Historial de chat con AI Assistant
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -213,9 +272,10 @@ class InspectionDetail(Inspection):
|
|||||||
|
|
||||||
# AI Configuration Schemas
|
# AI Configuration Schemas
|
||||||
class AIConfigurationBase(BaseModel):
|
class AIConfigurationBase(BaseModel):
|
||||||
provider: str # openai, gemini
|
provider: str # openai, gemini, anthropic
|
||||||
api_key: str
|
api_key: str
|
||||||
model_name: str
|
model_name: Optional[str] = None
|
||||||
|
logo_url: Optional[str] = None
|
||||||
|
|
||||||
class AIConfigurationCreate(AIConfigurationBase):
|
class AIConfigurationCreate(AIConfigurationBase):
|
||||||
pass
|
pass
|
||||||
@@ -224,6 +284,7 @@ class AIConfigurationUpdate(BaseModel):
|
|||||||
provider: Optional[str] = None
|
provider: Optional[str] = None
|
||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
model_name: Optional[str] = None
|
model_name: Optional[str] = None
|
||||||
|
logo_url: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
class AIConfiguration(AIConfigurationBase):
|
class AIConfiguration(AIConfigurationBase):
|
||||||
@@ -280,3 +341,31 @@ class InspectionListItem(BaseModel):
|
|||||||
flagged_items: int
|
flagged_items: int
|
||||||
started_at: Optional[datetime]
|
started_at: Optional[datetime]
|
||||||
completed_at: Optional[datetime]
|
completed_at: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
# Audit Log Schemas
|
||||||
|
class AuditLogBase(BaseModel):
|
||||||
|
action: str
|
||||||
|
entity_type: str
|
||||||
|
field_name: Optional[str] = None
|
||||||
|
old_value: Optional[str] = None
|
||||||
|
new_value: Optional[str] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
class AuditLog(AuditLogBase):
|
||||||
|
id: int
|
||||||
|
inspection_id: int
|
||||||
|
answer_id: Optional[int] = None
|
||||||
|
user_id: int
|
||||||
|
user_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class AnswerEdit(BaseModel):
|
||||||
|
answer_value: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
is_flagged: Optional[bool] = None
|
||||||
|
edit_comment: Optional[str] = None # Comentario del admin sobre por qué editó
|
||||||
|
|||||||
25
backend/docker.ps1
Normal file
25
backend/docker.ps1
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
Clear-Host
|
||||||
|
|
||||||
|
# Input
|
||||||
|
$version = Read-Host "Ingrese el numero de version (ej: 1.0.34)"
|
||||||
|
|
||||||
|
Write-Host "`n=== Construyendo imagen dymai/syntria-backend:$version ===`n"
|
||||||
|
docker build -t "dymai/syntria-backend:$version" .
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "`nERROR: El build fallo. No se realizara el push." -ForegroundColor Red
|
||||||
|
pause
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n=== Subiendo imagen a Docker Hub ===`n"
|
||||||
|
docker push "dymai/syntria-backend:$version"
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "`nERROR: El push fallo." -ForegroundColor Red
|
||||||
|
pause
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green
|
||||||
|
|
||||||
32
backend/migrate_ai_prompt.py
Normal file
32
backend/migrate_ai_prompt.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add ai_prompt column to questions table
|
||||||
|
Date: 2025-11-21
|
||||||
|
Description: Adds ai_prompt TEXT column for custom AI analysis prompts per question
|
||||||
|
"""
|
||||||
|
|
||||||
|
# SQL Migration Script
|
||||||
|
sql_statements = [
|
||||||
|
# Add ai_prompt column
|
||||||
|
"""
|
||||||
|
ALTER TABLE questions
|
||||||
|
ADD COLUMN ai_prompt TEXT;
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
# To apply this migration, run these SQL statements in your PostgreSQL database:
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("=" * 80)
|
||||||
|
print("MIGRATION: Add ai_prompt to questions table")
|
||||||
|
print("=" * 80)
|
||||||
|
print("\nExecute the following SQL statements in your PostgreSQL database:\n")
|
||||||
|
|
||||||
|
for i, statement in enumerate(sql_statements, 1):
|
||||||
|
print(f"-- Statement {i}")
|
||||||
|
print(statement.strip())
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("\nTo verify the migration:")
|
||||||
|
print("SELECT column_name, data_type FROM information_schema.columns")
|
||||||
|
print("WHERE table_name = 'questions' AND column_name = 'ai_prompt';")
|
||||||
|
print("=" * 80)
|
||||||
44
backend/migrations/add_question_audit_log.sql
Normal file
44
backend/migrations/add_question_audit_log.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Migration: Add question_audit_log table
|
||||||
|
-- Date: 2025-11-27
|
||||||
|
-- Description: Add audit logging for question changes
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS question_audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
question_id INTEGER NOT NULL,
|
||||||
|
checklist_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
field_name VARCHAR(100),
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
comment TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- Foreign keys
|
||||||
|
CONSTRAINT fk_question_audit_question
|
||||||
|
FOREIGN KEY (question_id)
|
||||||
|
REFERENCES questions(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CONSTRAINT fk_question_audit_checklist
|
||||||
|
FOREIGN KEY (checklist_id)
|
||||||
|
REFERENCES checklists(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CONSTRAINT fk_question_audit_user
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better query performance
|
||||||
|
CREATE INDEX idx_question_audit_question_id ON question_audit_log(question_id);
|
||||||
|
CREATE INDEX idx_question_audit_checklist_id ON question_audit_log(checklist_id);
|
||||||
|
CREATE INDEX idx_question_audit_created_at ON question_audit_log(created_at);
|
||||||
|
CREATE INDEX idx_question_audit_action ON question_audit_log(action);
|
||||||
|
|
||||||
|
-- Add comment to table
|
||||||
|
COMMENT ON TABLE question_audit_log IS 'Registro de auditoría para cambios en preguntas de checklists';
|
||||||
|
COMMENT ON COLUMN question_audit_log.action IS 'Tipo de acción: created, updated, deleted';
|
||||||
|
COMMENT ON COLUMN question_audit_log.field_name IS 'Nombre del campo modificado (solo para updates)';
|
||||||
|
COMMENT ON COLUMN question_audit_log.old_value IS 'Valor anterior del campo';
|
||||||
|
COMMENT ON COLUMN question_audit_log.new_value IS 'Valor nuevo del campo';
|
||||||
10
backend/migrations/rename_client_name_to_order_number.sql
Normal file
10
backend/migrations/rename_client_name_to_order_number.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Migration: Rename client_name to order_number
|
||||||
|
-- Date: 2025-11-27
|
||||||
|
-- Description: Cambiar campo client_name a order_number en tabla inspections
|
||||||
|
|
||||||
|
-- Renombrar la columna
|
||||||
|
ALTER TABLE inspections
|
||||||
|
RENAME COLUMN client_name TO order_number;
|
||||||
|
|
||||||
|
-- Actualizar comentario de la columna
|
||||||
|
COMMENT ON COLUMN inspections.order_number IS 'Número de pedido asociado a la inspección';
|
||||||
@@ -10,8 +10,12 @@ python-jose[cryptography]==3.3.0
|
|||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
bcrypt==4.0.1
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
openai==1.10.0
|
openai==1.57.4
|
||||||
|
anthropic==0.40.0
|
||||||
google-generativeai==0.3.2
|
google-generativeai==0.3.2
|
||||||
Pillow==10.2.0
|
Pillow==10.2.0
|
||||||
reportlab==4.0.9
|
reportlab==4.0.9
|
||||||
|
pypdf==4.0.1
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
boto3==1.34.89
|
||||||
|
requests==2.31.0
|
||||||
52
backend/s3test.py
Normal file
52
backend/s3test.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
MINIO_ENDPOINT = "minioapi.rshtech.com.py"
|
||||||
|
MINIO_ACCESS_KEY = "6uEIJyKR2Fi4UXiSgIeG"
|
||||||
|
MINIO_SECRET_KEY = "8k0kYuvxD9ePuvjdxvDk8WkGhhlaaee8BxU1mqRW"
|
||||||
|
MINIO_IMAGE_BUCKET = "images"
|
||||||
|
MINIO_PDF_BUCKET = "pdfs"
|
||||||
|
MINIO_SECURE = True # HTTPS
|
||||||
|
MINIO_PORT = 443
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
endpoint_url = f"https://{MINIO_ENDPOINT}:{MINIO_PORT}" if MINIO_SECURE \
|
||||||
|
else f"http://{MINIO_ENDPOINT}:{MINIO_PORT}"
|
||||||
|
|
||||||
|
# Crear cliente S3 compatible para MinIO
|
||||||
|
s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=endpoint_url,
|
||||||
|
aws_access_key_id=MINIO_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=MINIO_SECRET_KEY,
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
region_name="us-east-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("🔍 Probando conexión…")
|
||||||
|
|
||||||
|
# Listar buckets
|
||||||
|
response = s3.list_buckets()
|
||||||
|
print("✅ Conexión exitosa. Buckets disponibles:")
|
||||||
|
for bucket in response.get("Buckets", []):
|
||||||
|
print(f" - {bucket['Name']}")
|
||||||
|
|
||||||
|
# Verificar acceso a buckets específicos
|
||||||
|
for bucket_name in [MINIO_IMAGE_BUCKET, MINIO_PDF_BUCKET]:
|
||||||
|
try:
|
||||||
|
s3.head_bucket(Bucket=bucket_name)
|
||||||
|
print(f"✔ Acceso OK al bucket: {bucket_name}")
|
||||||
|
except ClientError:
|
||||||
|
print(f"❌ No se pudo acceder al bucket: {bucket_name}")
|
||||||
|
|
||||||
|
print("🎉 Test finalizado correctamente.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("❌ Error:", e)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
30
backend/test_minio.py
Normal file
30
backend/test_minio.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import os
|
||||||
|
from app.core import config as app_config
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
|
||||||
|
scheme = 'https' if app_config.MINIO_SECURE else 'http'
|
||||||
|
endpoint = f"{scheme}://{os.getenv('MINIO_ENDPOINT', 'localhost')}:{app_config.MINIO_PORT}"
|
||||||
|
access_key = os.getenv('MINIO_ACCESS_KEY', 'minioadmin')
|
||||||
|
secret_key = os.getenv('MINIO_SECRET_KEY', 'minioadmin')
|
||||||
|
bucket = os.getenv('MINIO_IMAGE_BUCKET', 'images')
|
||||||
|
|
||||||
|
s3 = boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url=endpoint,
|
||||||
|
aws_access_key_id=access_key,
|
||||||
|
aws_secret_access_key=secret_key,
|
||||||
|
config=Config(signature_version='s3v4'),
|
||||||
|
region_name='us-east-1'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# List buckets
|
||||||
|
response = s3.list_buckets()
|
||||||
|
print('Buckets:', [b['Name'] for b in response['Buckets']])
|
||||||
|
# Upload test file
|
||||||
|
with open('test_minio.py', 'rb') as f:
|
||||||
|
s3.upload_fileobj(f, bucket, 'test_minio.py')
|
||||||
|
print(f'Archivo subido a bucket {bucket} correctamente.')
|
||||||
|
except Exception as e:
|
||||||
|
print('Error:', e)
|
||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: dymai/syntria-backend:1.0.12
|
image: dymai/syntria-backend:1.0.15
|
||||||
container_name: syntria-backend-prod
|
container_name: syntria-backend-prod
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: dymai/syntria-frontend:1.0.18
|
image: dymai/syntria-frontend:1.0.24
|
||||||
container_name: syntria-frontend-prod
|
container_name: syntria-frontend-prod
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
0
docker.ps1
Normal file
0
docker.ps1
Normal file
155
docs/pdf-extraction-improvements.md
Normal file
155
docs/pdf-extraction-improvements.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Mejoras en la Extracción de PDFs con IA
|
||||||
|
|
||||||
|
## Versión Backend: 1.0.95
|
||||||
|
|
||||||
|
## Problema Original
|
||||||
|
|
||||||
|
El sistema tenía limitaciones al procesar PDFs con IA:
|
||||||
|
|
||||||
|
1. **Límites muy pequeños**: Solo extraía 2,000-4,000 caracteres
|
||||||
|
2. **Sin manejo de duplicaciones**: Páginas repetidas se procesaban múltiples veces
|
||||||
|
3. **No aprovechaba contextos largos**: Los modelos modernos soportan millones de tokens
|
||||||
|
4. **Falta de información**: No reportaba páginas procesadas o si el contenido fue truncado
|
||||||
|
|
||||||
|
## Solución Implementada
|
||||||
|
|
||||||
|
### 1. Función Centralizada de Extracción
|
||||||
|
|
||||||
|
Nueva función `extract_pdf_text_smart()` que:
|
||||||
|
- ✅ Extrae texto de forma inteligente
|
||||||
|
- ✅ Detecta y evita páginas duplicadas
|
||||||
|
- ✅ Maneja límites configurables
|
||||||
|
- ✅ Reporta estadísticas completas (páginas, caracteres, truncado)
|
||||||
|
- ✅ Manejo robusto de errores
|
||||||
|
|
||||||
|
```python
|
||||||
|
pdf_result = extract_pdf_text_smart(pdf_content, max_chars=50000)
|
||||||
|
# Retorna:
|
||||||
|
# {
|
||||||
|
# 'text': '...',
|
||||||
|
# 'pages': 10,
|
||||||
|
# 'pages_processed': 9, # Si una página estaba duplicada
|
||||||
|
# 'total_chars': 45000,
|
||||||
|
# 'truncated': False,
|
||||||
|
# 'success': True
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Límites Optimizados por Caso de Uso
|
||||||
|
|
||||||
|
| Endpoint | Límite Anterior | Límite Nuevo | Modelo Objetivo |
|
||||||
|
|----------|----------------|--------------|-----------------|
|
||||||
|
| `/api/analyze-image` (OpenAI) | 4,000 chars | 30,000 chars | GPT-4 (128k tokens) |
|
||||||
|
| `/api/analyze-image` (Gemini) | 4,000 chars | 100,000 chars | Gemini 1.5/2.0 (2M tokens) |
|
||||||
|
| `/api/ai/chat-assistant` | 2,000 chars | 50,000 chars | Equilibrado para contexto |
|
||||||
|
|
||||||
|
### 3. Detección de Duplicaciones
|
||||||
|
|
||||||
|
El sistema ahora verifica si el contenido de una página ya existe antes de agregarlo:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if page_text.strip() not in full_text:
|
||||||
|
full_text += f"\n--- Página {page_num}/{total_pages} ---\n{page_text.strip()}\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto previene:
|
||||||
|
- PDFs con páginas idénticas repetidas
|
||||||
|
- Documentos mal generados con contenido duplicado
|
||||||
|
- Uso innecesario de tokens en el análisis IA
|
||||||
|
|
||||||
|
### 4. Información Mejorada
|
||||||
|
|
||||||
|
El sistema ahora reporta:
|
||||||
|
- **Páginas totales**: Total de páginas en el PDF
|
||||||
|
- **Páginas procesadas**: Páginas únicas con contenido
|
||||||
|
- **Caracteres totales**: Tamaño real del texto extraído
|
||||||
|
- **Indicador de truncado**: Si el PDF fue limitado
|
||||||
|
|
||||||
|
Ejemplo de output:
|
||||||
|
```
|
||||||
|
📄 PDF procesado: manual-vehiculo.pdf - 87450 caracteres, 8/10 páginas (TRUNCADO)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Capacidades de Contexto por Modelo
|
||||||
|
|
||||||
|
### OpenAI GPT-4
|
||||||
|
- **Contexto**: ~128,000 tokens (~500,000 caracteres)
|
||||||
|
- **Límite aplicado**: 30,000 caracteres
|
||||||
|
- **Razón**: Balance entre contexto útil y costo
|
||||||
|
|
||||||
|
### Gemini 1.5/2.0 Pro
|
||||||
|
- **Contexto**: 2,000,000 tokens (~8,000,000 caracteres)
|
||||||
|
- **Límite aplicado**: 100,000 caracteres
|
||||||
|
- **Razón**: Aprovechar contexto masivo sin sobrecargar
|
||||||
|
|
||||||
|
### Chat Assistant
|
||||||
|
- **Límite**: 50,000 caracteres
|
||||||
|
- **Razón**: Incluye historial + contexto de fotos + PDF
|
||||||
|
|
||||||
|
## Casos de Uso Soportados
|
||||||
|
|
||||||
|
### ✅ PDFs Pequeños (1-5 páginas)
|
||||||
|
Extracción completa sin truncado
|
||||||
|
|
||||||
|
### ✅ PDFs Medianos (5-20 páginas)
|
||||||
|
Extracción completa o parcial según contenido
|
||||||
|
|
||||||
|
### ✅ PDFs Grandes (20+ páginas)
|
||||||
|
Extracción inteligente con truncado después de límite
|
||||||
|
|
||||||
|
### ✅ PDFs con Páginas Duplicadas
|
||||||
|
Detección automática y eliminación
|
||||||
|
|
||||||
|
### ✅ Múltiples PDFs en Chat
|
||||||
|
Cada uno procesado independientemente con su límite
|
||||||
|
|
||||||
|
## Indicadores de Estado
|
||||||
|
|
||||||
|
### En Logs del Servidor
|
||||||
|
```
|
||||||
|
📄 PDF procesado: documento.pdf - 25000 caracteres, 10/10 páginas
|
||||||
|
📄 PDF procesado: manual.pdf - 50000 caracteres, 15/20 páginas (TRUNCADO)
|
||||||
|
```
|
||||||
|
|
||||||
|
### En Respuesta al Cliente
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"attached_files": [
|
||||||
|
{
|
||||||
|
"filename": "manual.pdf",
|
||||||
|
"type": "application/pdf",
|
||||||
|
"pages": 20,
|
||||||
|
"pages_processed": 15,
|
||||||
|
"total_chars": 75000,
|
||||||
|
"truncated": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Próximas Mejoras Potenciales
|
||||||
|
|
||||||
|
1. **Chunking Inteligente**: Para PDFs muy grandes, dividir en chunks semánticos
|
||||||
|
2. **OCR Integrado**: Detectar PDFs escaneados y aplicar OCR automático
|
||||||
|
3. **Resumen Automático**: Para PDFs grandes, generar resumen antes de análisis
|
||||||
|
4. **Cache de Extracciones**: Guardar texto extraído en DB para reutilización
|
||||||
|
|
||||||
|
## Migración
|
||||||
|
|
||||||
|
No requiere migración de base de datos. Los cambios son retrocompatibles.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Para probar las mejoras:
|
||||||
|
|
||||||
|
1. **PDF pequeño** (< 10 páginas): Debe procesarse completo
|
||||||
|
2. **PDF grande** (> 50 páginas): Debe truncarse y reportar info
|
||||||
|
3. **PDF con duplicados**: Debe eliminar páginas repetidas
|
||||||
|
4. **Múltiples PDFs**: Cada uno procesado independientemente
|
||||||
|
|
||||||
|
## Notas Técnicas
|
||||||
|
|
||||||
|
- La función `extract_pdf_text_smart()` está en `main.py` línea ~210
|
||||||
|
- Usa `pypdf.PdfReader` para extracción
|
||||||
|
- Maneja encoding UTF-8 automáticamente
|
||||||
|
- Thread-safe (usa BytesIO)
|
||||||
177
docs/pdf-regeneration.md
Normal file
177
docs/pdf-regeneration.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Regeneración Automática de PDF al Editar Respuestas
|
||||||
|
|
||||||
|
## Descripción General
|
||||||
|
|
||||||
|
Se ha implementado la funcionalidad de regeneración automática del PDF de inspección cuando se editan respuestas en inspecciones completadas.
|
||||||
|
|
||||||
|
## Cambios Implementados
|
||||||
|
|
||||||
|
### 1. Nueva Función Reutilizable: `generate_inspection_pdf()`
|
||||||
|
|
||||||
|
**Ubicación**: `backend/app/main.py` (línea ~1046)
|
||||||
|
|
||||||
|
**Propósito**: Generar el PDF de una inspección y subirlo a S3.
|
||||||
|
|
||||||
|
**Parámetros**:
|
||||||
|
- `inspection_id: int` - ID de la inspección
|
||||||
|
- `db: Session` - Sesión de base de datos
|
||||||
|
|
||||||
|
**Retorna**: `str` - URL del PDF generado en S3
|
||||||
|
|
||||||
|
**Características**:
|
||||||
|
- Genera PDF profesional con diseño A4
|
||||||
|
- Incluye toda la información de la inspección
|
||||||
|
- Sube automáticamente a S3/MinIO
|
||||||
|
- Sobrescribe PDF existente si ya existe
|
||||||
|
- Maneja errores y excepciones
|
||||||
|
|
||||||
|
### 2. Actualización de `complete_inspection()`
|
||||||
|
|
||||||
|
**Ubicación**: `backend/app/main.py` (línea ~1358)
|
||||||
|
|
||||||
|
**Cambios**:
|
||||||
|
- Removido código duplicado de generación de PDF
|
||||||
|
- Ahora usa la función `generate_inspection_pdf()`
|
||||||
|
- Código más limpio y mantenible
|
||||||
|
|
||||||
|
**Antes**:
|
||||||
|
```python
|
||||||
|
# 300+ líneas de código de generación de PDF inline
|
||||||
|
```
|
||||||
|
|
||||||
|
**Después**:
|
||||||
|
```python
|
||||||
|
# Generar PDF usando función reutilizable
|
||||||
|
pdf_url = generate_inspection_pdf(inspection_id, db)
|
||||||
|
inspection.pdf_url = pdf_url
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Actualización de `update_answer()`
|
||||||
|
|
||||||
|
**Ubicación**: `backend/app/main.py` (línea ~1497)
|
||||||
|
|
||||||
|
**Nuevas Funcionalidades**:
|
||||||
|
|
||||||
|
1. **Verificación de Estado**: Comprueba si la inspección está completada
|
||||||
|
2. **Recálculo de Puntuación**: Actualiza score, porcentaje y contadores
|
||||||
|
3. **Regeneración de PDF**: Genera nuevo PDF con los cambios
|
||||||
|
4. **Manejo de Errores**: No interrumpe la actualización si falla la generación del PDF
|
||||||
|
|
||||||
|
**Flujo de Trabajo**:
|
||||||
|
```python
|
||||||
|
1. Usuario edita respuesta
|
||||||
|
2. Backend actualiza Answer en BD
|
||||||
|
3. Backend verifica si inspection.status == "completed"
|
||||||
|
4. Si está completada:
|
||||||
|
a. Recalcula score total
|
||||||
|
b. Recalcula porcentaje
|
||||||
|
c. Recalcula items críticos
|
||||||
|
d. Genera nuevo PDF
|
||||||
|
e. Actualiza inspection.pdf_url
|
||||||
|
5. Retorna Answer actualizado
|
||||||
|
```
|
||||||
|
|
||||||
|
## Casos de Uso
|
||||||
|
|
||||||
|
### Caso 1: Editar Respuesta en Inspección en Progreso
|
||||||
|
```
|
||||||
|
- Usuario edita respuesta
|
||||||
|
- Respuesta se actualiza
|
||||||
|
- PDF NO se regenera (inspección no completada)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caso 2: Editar Respuesta en Inspección Completada
|
||||||
|
```
|
||||||
|
- Usuario edita respuesta
|
||||||
|
- Respuesta se actualiza
|
||||||
|
- Sistema detecta que inspección está completada
|
||||||
|
- Score se recalcula automáticamente
|
||||||
|
- PDF se regenera con los nuevos datos
|
||||||
|
- PDF anterior es sobrescrito en S3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ventajas de la Nueva Implementación
|
||||||
|
|
||||||
|
1. **DRY (Don't Repeat Yourself)**: Código de generación de PDF existe una sola vez
|
||||||
|
2. **Mantenibilidad**: Cambios al PDF solo se hacen en un lugar
|
||||||
|
3. **Automatización**: PDFs siempre reflejan el estado actual
|
||||||
|
4. **Consistencia**: Mismo diseño profesional en todas partes
|
||||||
|
5. **Robustez**: Manejo de errores sin interrumpir flujo principal
|
||||||
|
|
||||||
|
## Estructura del PDF Generado
|
||||||
|
|
||||||
|
El PDF incluye:
|
||||||
|
|
||||||
|
### Portada
|
||||||
|
- Título e ID de inspección
|
||||||
|
- Cuadro de información del vehículo (azul)
|
||||||
|
- Cuadro de información del cliente y mecánico (verde)
|
||||||
|
- Resumen de puntuación con colores según porcentaje
|
||||||
|
|
||||||
|
### Detalle de Inspección
|
||||||
|
- Agrupado por secciones
|
||||||
|
- Cada pregunta con:
|
||||||
|
- Icono de estado (✓ ok, ⚠ warning, ✕ critical)
|
||||||
|
- Respuesta y estado
|
||||||
|
- Comentarios
|
||||||
|
- Galería de imágenes (6 por fila)
|
||||||
|
|
||||||
|
### Footer
|
||||||
|
- Timestamp de generación
|
||||||
|
|
||||||
|
## Logs y Debugging
|
||||||
|
|
||||||
|
El sistema imprime logs útiles:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Al regenerar PDF
|
||||||
|
🔄 Regenerando PDF para inspección completada #123
|
||||||
|
|
||||||
|
# Al completar regeneración
|
||||||
|
✅ PDF generado y subido a S3: https://...
|
||||||
|
|
||||||
|
# Si hay error
|
||||||
|
❌ Error regenerando PDF: [detalle]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Versión del Backend
|
||||||
|
|
||||||
|
**Versión actual**: `1.0.26`
|
||||||
|
|
||||||
|
Se incrementó la versión para reflejar esta nueva funcionalidad.
|
||||||
|
|
||||||
|
## Notas Técnicas
|
||||||
|
|
||||||
|
### S3/MinIO
|
||||||
|
- Los PDFs sobrescriben el archivo anterior con el mismo nombre
|
||||||
|
- Ruta: `{año}/{mes}/inspeccion_{id}_{placa}.pdf`
|
||||||
|
- Content-Type: `application/pdf`
|
||||||
|
|
||||||
|
### Base de Datos
|
||||||
|
- Campo `inspection.pdf_url` se actualiza automáticamente
|
||||||
|
- Score, porcentaje y flagged_items_count se recalculan
|
||||||
|
- Todo en una sola transacción
|
||||||
|
|
||||||
|
### Manejo de Errores
|
||||||
|
- Si falla la generación del PDF, se registra el error
|
||||||
|
- La actualización de la respuesta NO se revierte
|
||||||
|
- Se imprime traceback completo para debugging
|
||||||
|
|
||||||
|
## Próximos Pasos Sugeridos
|
||||||
|
|
||||||
|
1. ✅ Implementar regeneración de PDF (COMPLETADO)
|
||||||
|
2. ⏳ Ejecutar migraciones SQL para employee_code
|
||||||
|
3. ⏳ Probar flujo completo en ambiente de desarrollo
|
||||||
|
4. ⏳ Considerar notificación a n8n cuando se edita inspección completada
|
||||||
|
5. ⏳ Agregar campo `updated_at` a inspecciones para tracking de cambios
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Para probar la funcionalidad:
|
||||||
|
|
||||||
|
1. Completar una inspección
|
||||||
|
2. Verificar que se genera el PDF
|
||||||
|
3. Editar una respuesta (cambiar status, comentario, etc.)
|
||||||
|
4. Verificar en logs que se regenera el PDF
|
||||||
|
5. Descargar el PDF y confirmar que refleja los cambios
|
||||||
|
6. Verificar que el score se recalculó correctamente
|
||||||
196
docs/webhook-n8n.md
Normal file
196
docs/webhook-n8n.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Documentación de Webhook - n8n
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
El endpoint configurado en `.env`:
|
||||||
|
```
|
||||||
|
NOTIFICACION_ENDPOINT=https://n8nw.comercialarmin.com.py/webhook/53284540-edc4-418f-b1bf-a70a805f8212
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evento: Inspección Completada
|
||||||
|
|
||||||
|
### Cuándo se envía
|
||||||
|
Cuando se completa una inspección (endpoint: `POST /api/inspections/{id}/complete`)
|
||||||
|
|
||||||
|
### Estructura del JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tipo": "inspeccion_completada",
|
||||||
|
"inspeccion": {
|
||||||
|
"id": 123,
|
||||||
|
"estado": "completed",
|
||||||
|
"or_number": "OR-001",
|
||||||
|
"work_order_number": "WO-123",
|
||||||
|
"vehiculo": {
|
||||||
|
"placa": "ABC-123",
|
||||||
|
"marca": "Toyota",
|
||||||
|
"modelo": "Corolla 2020",
|
||||||
|
"kilometraje": 50000
|
||||||
|
},
|
||||||
|
"cliente": "Juan Pérez",
|
||||||
|
"mecanico": {
|
||||||
|
"id": 5,
|
||||||
|
"nombre": "Carlos Méndez",
|
||||||
|
"email": "carlos@example.com",
|
||||||
|
"codigo_operario": "OPR-001"
|
||||||
|
},
|
||||||
|
"checklist": {
|
||||||
|
"id": 1,
|
||||||
|
"nombre": "Inspección Vehicular Completa"
|
||||||
|
},
|
||||||
|
"puntuacion": {
|
||||||
|
"obtenida": 85,
|
||||||
|
"maxima": 100,
|
||||||
|
"porcentaje": 85.0,
|
||||||
|
"items_criticos": 2
|
||||||
|
},
|
||||||
|
"fechas": {
|
||||||
|
"inicio": "2025-11-26T10:30:00",
|
||||||
|
"completado": "2025-11-26T11:45:00"
|
||||||
|
},
|
||||||
|
"pdf_url": "https://minioapi.ayutec.es/pdfs/2025/11/inspeccion_123_ABC-123.pdf",
|
||||||
|
"firma": "data:image/png;base64,..."
|
||||||
|
},
|
||||||
|
"respuestas": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"pregunta": {
|
||||||
|
"id": 10,
|
||||||
|
"texto": "¿Estado de los neumáticos?",
|
||||||
|
"seccion": "Neumáticos",
|
||||||
|
"orden": 1
|
||||||
|
},
|
||||||
|
"respuesta": "ok",
|
||||||
|
"estado": "ok",
|
||||||
|
"comentario": "Neumáticos en buen estado",
|
||||||
|
"observaciones": "Presión correcta en las 4 ruedas",
|
||||||
|
"puntos_obtenidos": 1,
|
||||||
|
"es_critico": false,
|
||||||
|
"imagenes": [
|
||||||
|
{
|
||||||
|
"id": 100,
|
||||||
|
"url": "https://minioapi.ayutec.es/images/2025/11/foto1.jpg",
|
||||||
|
"filename": "neumatico_delantero.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"url": "https://minioapi.ayutec.es/images/2025/11/foto2.jpg",
|
||||||
|
"filename": "neumatico_trasero.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ai_analysis": {
|
||||||
|
"status": "ok",
|
||||||
|
"observations": "Los neumáticos presentan un desgaste uniforme...",
|
||||||
|
"recommendation": "Continuar con el mantenimiento preventivo",
|
||||||
|
"confidence": 0.95
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"pregunta": {
|
||||||
|
"id": 11,
|
||||||
|
"texto": "¿Luces delanteras funcionan?",
|
||||||
|
"seccion": "Iluminación",
|
||||||
|
"orden": 2
|
||||||
|
},
|
||||||
|
"respuesta": "warning",
|
||||||
|
"estado": "warning",
|
||||||
|
"comentario": "Faro izquierdo opaco",
|
||||||
|
"observaciones": "Requiere restauración de faro",
|
||||||
|
"puntos_obtenidos": 0.5,
|
||||||
|
"es_critico": true,
|
||||||
|
"imagenes": [
|
||||||
|
{
|
||||||
|
"id": 102,
|
||||||
|
"url": "https://minioapi.ayutec.es/images/2025/11/foto3.jpg",
|
||||||
|
"filename": "faro_izquierdo.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ai_analysis": {
|
||||||
|
"status": "minor",
|
||||||
|
"observations": "Se detecta opacidad en el faro izquierdo...",
|
||||||
|
"recommendation": "Pulir o restaurar el lente del faro",
|
||||||
|
"confidence": 0.9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timestamp": "2025-11-26T11:45:30.123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Campos Importantes
|
||||||
|
|
||||||
|
### Imágenes
|
||||||
|
- Cada respuesta incluye un array `imagenes` con:
|
||||||
|
- `id`: ID del archivo en la base de datos
|
||||||
|
- `url`: **URL directa** de la imagen en MinIO (lista para descargar/mostrar)
|
||||||
|
- `filename`: Nombre original del archivo
|
||||||
|
|
||||||
|
### AI Analysis
|
||||||
|
- Si la pregunta fue analizada por IA, incluye:
|
||||||
|
- `status`: ok/minor/critical
|
||||||
|
- `observations`: Observaciones del análisis
|
||||||
|
- `recommendation`: Recomendaciones
|
||||||
|
- `confidence`: Nivel de confianza (0-1)
|
||||||
|
|
||||||
|
### Código de Operario
|
||||||
|
- Se incluye en `inspeccion.mecanico.codigo_operario`
|
||||||
|
- Se copia automáticamente del perfil del mecánico al crear la inspección
|
||||||
|
|
||||||
|
### PDF
|
||||||
|
- URL del PDF generado en `inspeccion.pdf_url`
|
||||||
|
- Incluye miniaturas de todas las imágenes
|
||||||
|
|
||||||
|
## Uso en n8n
|
||||||
|
|
||||||
|
### 1. Webhook Trigger
|
||||||
|
Configura un nodo Webhook con la URL del archivo `.env`
|
||||||
|
|
||||||
|
### 2. Filtrar por tipo
|
||||||
|
```javascript
|
||||||
|
// Verificar si es una inspección completada
|
||||||
|
{{ $json.tipo === "inspeccion_completada" }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Acceder a las imágenes
|
||||||
|
```javascript
|
||||||
|
// Obtener todas las URLs de imágenes
|
||||||
|
{{ $json.respuestas.map(r => r.imagenes.map(i => i.url)).flat() }}
|
||||||
|
|
||||||
|
// Primera imagen de cada respuesta
|
||||||
|
{{ $json.respuestas.map(r => r.imagenes[0]?.url) }}
|
||||||
|
|
||||||
|
// Imágenes de respuestas críticas
|
||||||
|
{{ $json.respuestas.filter(r => r.es_critico).map(r => r.imagenes).flat() }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Descargar imágenes
|
||||||
|
Las URLs son públicas y directas, se pueden:
|
||||||
|
- Descargar con HTTP Request
|
||||||
|
- Enviar por email como adjuntos
|
||||||
|
- Procesar con Computer Vision
|
||||||
|
- Subir a otro servicio (Google Drive, Dropbox, etc.)
|
||||||
|
|
||||||
|
### 5. Ejemplo: Enviar por email
|
||||||
|
```javascript
|
||||||
|
// En un nodo Email
|
||||||
|
To: {{ $json.inspeccion.cliente_email }}
|
||||||
|
Subject: Inspección Completada - {{ $json.inspeccion.vehiculo.placa }}
|
||||||
|
Attachments: {{ $json.inspeccion.pdf_url }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
El backend imprime logs detallados:
|
||||||
|
```
|
||||||
|
🚀 Enviando inspección #123 a n8n...
|
||||||
|
📤 Enviando 15 respuestas con imágenes a n8n...
|
||||||
|
✅ Inspección #123 enviada exitosamente a n8n
|
||||||
|
- 15 respuestas
|
||||||
|
- 23 imágenes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
- El webhook es HTTPS
|
||||||
|
- Las URLs de imágenes son públicas en MinIO
|
||||||
|
- No se envían passwords ni tokens
|
||||||
|
- Se incluyen solo datos relevantes de la inspección
|
||||||
168
frontend/PWA-UPDATE-GUIDE.md
Normal file
168
frontend/PWA-UPDATE-GUIDE.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Sistema de Actualización PWA - AYUTEC
|
||||||
|
|
||||||
|
## 🚀 Características
|
||||||
|
|
||||||
|
- ✅ **Detección automática** de nuevas versiones
|
||||||
|
- ✅ **Modal de actualización** grande y visible
|
||||||
|
- ✅ **Service Worker** con estrategia Network-First
|
||||||
|
- ✅ **Cache inteligente** para funcionamiento offline
|
||||||
|
- ✅ **Actualización forzada** al usuario cuando hay nueva versión
|
||||||
|
|
||||||
|
## 📱 Instalación como PWA
|
||||||
|
|
||||||
|
### En Android/iOS:
|
||||||
|
1. Abre la app en Chrome/Safari
|
||||||
|
2. Toca el menú (⋮)
|
||||||
|
3. Selecciona "Agregar a pantalla de inicio"
|
||||||
|
4. Confirma la instalación
|
||||||
|
|
||||||
|
### En Desktop:
|
||||||
|
1. Abre la app en Chrome/Edge
|
||||||
|
2. Haz clic en el ícono de instalación (➕) en la barra de direcciones
|
||||||
|
3. Confirma "Instalar"
|
||||||
|
|
||||||
|
## 🔄 Proceso de Actualización
|
||||||
|
|
||||||
|
### Para el Usuario:
|
||||||
|
1. Cuando hay una actualización, aparece automáticamente un **modal grande**
|
||||||
|
2. El modal muestra: "¡Nueva Actualización!"
|
||||||
|
3. Botón grande: **"🚀 ACTUALIZAR AHORA"**
|
||||||
|
4. Al presionar, la app se recarga con la nueva versión
|
||||||
|
|
||||||
|
### Para el Desarrollador:
|
||||||
|
|
||||||
|
#### Opción 1: Script Automático (Recomendado)
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
.\update-version.ps1
|
||||||
|
```
|
||||||
|
Este script:
|
||||||
|
- Incrementa automáticamente la versión patch (1.0.87 → 1.0.88)
|
||||||
|
- Actualiza `package.json`
|
||||||
|
- Actualiza `public/service-worker.js`
|
||||||
|
|
||||||
|
#### Opción 2: Manual
|
||||||
|
1. **Actualizar `package.json`:**
|
||||||
|
```json
|
||||||
|
"version": "1.0.88" // Incrementar número
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Actualizar `public/service-worker.js`:**
|
||||||
|
```javascript
|
||||||
|
const CACHE_NAME = 'ayutec-v1.0.88'; // Mismo número
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Hacer build y deploy:**
|
||||||
|
```powershell
|
||||||
|
npm run build
|
||||||
|
docker build -t tu-registry/checklist-frontend:latest .
|
||||||
|
docker push tu-registry/checklist-frontend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Cómo Funciona
|
||||||
|
|
||||||
|
### 1. Service Worker
|
||||||
|
- Registrado en `App.jsx`
|
||||||
|
- Cache con nombre versionado: `ayutec-v1.0.87`
|
||||||
|
- Estrategia: **Network First, Cache Fallback**
|
||||||
|
- Al cambiar la versión, se crea nuevo cache
|
||||||
|
|
||||||
|
### 2. Detección de Actualización
|
||||||
|
```javascript
|
||||||
|
// En App.jsx
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
// Nueva versión detectada
|
||||||
|
setUpdateAvailable(true)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Modal de Actualización
|
||||||
|
- Overlay negro semi-transparente (z-index: 9999)
|
||||||
|
- Modal animado con bounce
|
||||||
|
- Botón grande con gradiente
|
||||||
|
- **No se puede cerrar** - obliga a actualizar
|
||||||
|
|
||||||
|
### 4. Aplicación de Actualización
|
||||||
|
```javascript
|
||||||
|
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
// Activa el nuevo service worker
|
||||||
|
// Recarga la página automáticamente
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Versionado
|
||||||
|
|
||||||
|
Seguimos **Semantic Versioning**:
|
||||||
|
- **MAJOR**: Cambios incompatibles (1.0.0 → 2.0.0)
|
||||||
|
- **MINOR**: Nueva funcionalidad compatible (1.0.0 → 1.1.0)
|
||||||
|
- **PATCH**: Correcciones de bugs (1.0.0 → 1.0.1)
|
||||||
|
|
||||||
|
El script `update-version.ps1` incrementa automáticamente **PATCH**.
|
||||||
|
|
||||||
|
## 🧪 Probar Localmente
|
||||||
|
|
||||||
|
1. **Compilar en modo producción:**
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Simular actualización:**
|
||||||
|
- Abre la app en navegador
|
||||||
|
- Incrementa versión en `service-worker.js`
|
||||||
|
- Recarga la página (Ctrl+F5)
|
||||||
|
- Debe aparecer el modal de actualización
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### El modal no aparece
|
||||||
|
- Verifica que el service worker esté registrado (F12 → Application → Service Workers)
|
||||||
|
- Asegúrate de cambiar el `CACHE_NAME` en `service-worker.js`
|
||||||
|
- Desregistra el SW antiguo: `Application → Service Workers → Unregister`
|
||||||
|
|
||||||
|
### La app no se actualiza
|
||||||
|
- Fuerza actualización: Ctrl+Shift+R (hard reload)
|
||||||
|
- Limpia cache del navegador
|
||||||
|
- Verifica que la nueva versión esté deployada
|
||||||
|
|
||||||
|
### PWA no se instala
|
||||||
|
- Verifica que `site.webmanifest` esté accesible
|
||||||
|
- Requiere HTTPS (excepto localhost)
|
||||||
|
- Verifica íconos en `/public/`
|
||||||
|
|
||||||
|
## 📝 Checklist de Deploy
|
||||||
|
|
||||||
|
- [ ] Incrementar versión con `update-version.ps1`
|
||||||
|
- [ ] Verificar que ambos archivos tengan la misma versión
|
||||||
|
- [ ] Hacer commit: `git commit -m "chore: bump version to X.X.X"`
|
||||||
|
- [ ] Build de producción: `npm run build`
|
||||||
|
- [ ] Build de Docker: `docker build -t frontend:vX.X.X .`
|
||||||
|
- [ ] Push a registry
|
||||||
|
- [ ] Deploy en servidor
|
||||||
|
- [ ] Verificar que usuarios vean el modal de actualización
|
||||||
|
|
||||||
|
## 🎯 Mejores Prácticas
|
||||||
|
|
||||||
|
1. **Siempre** incrementar versión antes de deploy
|
||||||
|
2. **Nunca** reutilizar números de versión
|
||||||
|
3. **Probar** localmente antes de deploy
|
||||||
|
4. **Documentar** cambios en commit message
|
||||||
|
5. **Notificar** a usuarios si es actualización crítica
|
||||||
|
|
||||||
|
## 🔐 Seguridad
|
||||||
|
|
||||||
|
- Service Worker solo funciona en HTTPS
|
||||||
|
- Manifest require `start_url` y `scope` correctos
|
||||||
|
- Cache no almacena datos sensibles (solo assets estáticos)
|
||||||
|
|
||||||
|
## 📱 Compatibilidad
|
||||||
|
|
||||||
|
- ✅ Chrome/Edge (Desktop y Mobile)
|
||||||
|
- ✅ Safari (iOS 11.3+)
|
||||||
|
- ✅ Firefox (Desktop y Mobile)
|
||||||
|
- ✅ Samsung Internet
|
||||||
|
- ⚠️ IE11 no soportado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Versión actual:** 1.0.87
|
||||||
|
**Última actualización:** 2025-11-30
|
||||||
27
frontend/buildFront.ps1
Normal file
27
frontend/buildFront.ps1
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
Clear-Host
|
||||||
|
|
||||||
|
# Pedir version
|
||||||
|
$version = Read-Host "Ingrese el numero de version (ej: 1.0.34)"
|
||||||
|
|
||||||
|
Write-Host "`n=== Construyendo imagen dymai/syntria-frontend:$version ===`n"
|
||||||
|
docker build -f Dockerfile.prod -t "dymai/syntria-frontend:$version" .
|
||||||
|
|
||||||
|
# Si build falla, no continuar
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "`nERROR: El build fallo. No se realizara el push." -ForegroundColor Red
|
||||||
|
pause
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n=== Subiendo imagen a Docker Hub ===`n"
|
||||||
|
docker push "dymai/syntria-frontend:$version"
|
||||||
|
|
||||||
|
# Si push falla, mostrar error
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "`nERROR: El push fallo." -ForegroundColor Red
|
||||||
|
pause
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green
|
||||||
|
|
||||||
@@ -2,10 +2,18 @@
|
|||||||
<html lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Syntria - Sistema Inteligente de Inspecciones</title>
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<meta name="description" content="Syntria: Sistema avanzado de inspecciones vehiculares con inteligencia artificial" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#4f46e5" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<title>AYUTEC - Sistema Inteligente de Inspecciones</title>
|
||||||
|
<meta name="description" content="AYUTEC: Sistema avanzado de inspecciones vehiculares con inteligencia artificial" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "checklist-frontend",
|
"name": "checklist-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.3.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"react-signature-canvas": "^1.0.6",
|
"react-signature-canvas": "^1.0.6",
|
||||||
"lucide-react": "^0.303.0",
|
"lucide-react": "^0.303.0",
|
||||||
"clsx": "^2.1.0"
|
"clsx": "^2.1.0",
|
||||||
|
"react-markdown": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
@@ -26,4 +27,4 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"vite": "^5.0.11"
|
"vite": "^5.0.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
frontend/public/ayutec_logo.webp
Normal file
BIN
frontend/public/ayutec_logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
3
frontend/public/favicon.svg
Normal file
3
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="28" height="28" viewBox="0 0 28 28"><image width="28" height="28" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAAVFBMVEVHcEwKJTcJKT0ULT4FJzwOIzQYLj0BHCwCJDcILEAcKTQJJDcQIzISAABTk8tSkcdVls4AJj00Z5AmUnVCeKdMiLwrWn86bplGgLEMNVAcRmYAHTJrIwFaAAAADnRSTlMAp9Va5pRHxvDGPH4oDQ8oGsYAAAD8SURBVCiRxZLJkoMgEEDVoCGaQHezyPL//zl0Y0wOM5dUpeYdpPAVvcEw/DPr8qdaJlS3k3F9VwoLRjqJOD/VRSGBq9acQMB7VxrJgsECXVgGyiYSqf0wqbpd8IeUuEuGtjERUQt157MGLyznyBvI8zpOqoGBXeo5tWcZcEJHLSoFORgVuztyCrNjDvCqx/ZWtgKSH62XeL2RrB8sbyRhKgE1aXwLTQV1nxByDuOrBZfAZC5ZXbdHn0AOKaXgctgdxVh7P0rKGUbnGwmjT1SCPyhTj+q5glBT+7aejsnmuTcCDPFi4DV1udotOybLEp84lILG66+Mn76mr/IDIP4ZVqH5o/IAAAAASUVORK5CYII="></image><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 940 B |
67
frontend/public/service-worker.js
Normal file
67
frontend/public/service-worker.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Service Worker para PWA con detección de actualizaciones
|
||||||
|
// IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión
|
||||||
|
const CACHE_NAME = 'ayutec-v1.3.8';
|
||||||
|
const urlsToCache = [
|
||||||
|
'/',
|
||||||
|
'/index.html'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Instalación del service worker
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('Service Worker: Installing version', CACHE_NAME);
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then((cache) => {
|
||||||
|
console.log('Service Worker: Caching files');
|
||||||
|
return cache.addAll(urlsToCache);
|
||||||
|
})
|
||||||
|
// NO hacer skipWaiting automáticamente - esperar a que el usuario lo active
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activación del service worker
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('Service Worker: Activating...');
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => {
|
||||||
|
if (cacheName !== CACHE_NAME) {
|
||||||
|
console.log('Service Worker: Deleting old cache:', cacheName);
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
// NO hacer claim automáticamente - solo cuando el usuario actualice manualmente
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Estrategia: Network First, fallback to Cache
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
// Clone la respuesta
|
||||||
|
const responseToCache = response.clone();
|
||||||
|
|
||||||
|
// Actualizar cache con la nueva respuesta
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(event.request, responseToCache);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Si falla la red, usar cache
|
||||||
|
return caches.match(event.request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mensaje para notificar actualización
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
24
frontend/public/site.webmanifest
Normal file
24
frontend/public/site.webmanifest
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "AYUTEC - Sistema de Inspecciones",
|
||||||
|
"short_name": "AYUTEC",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#4f46e5",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait"
|
||||||
|
}
|
||||||
4959
frontend/src/App.jsx
4959
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
332
frontend/src/QuestionAnswerInput.jsx
Normal file
332
frontend/src/QuestionAnswerInput.jsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderizador Dinámico de Campos de Respuesta
|
||||||
|
* Renderiza el input apropiado según la configuración de la pregunta
|
||||||
|
*/
|
||||||
|
export function QuestionAnswerInput({ question, value, onChange, onSave }) {
|
||||||
|
const config = question.options || {}
|
||||||
|
const questionType = config.type || question.type
|
||||||
|
|
||||||
|
// BOOLEAN (2 opciones)
|
||||||
|
if (questionType === 'boolean' && config.choices?.length === 2) {
|
||||||
|
const [choice1, choice2] = config.choices
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center cursor-pointer px-4 py-3 border-2 rounded-lg transition hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value={choice1.value}
|
||||||
|
checked={value === choice1.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
onSave?.()
|
||||||
|
}}
|
||||||
|
className="mr-3"
|
||||||
|
/>
|
||||||
|
<span className={`font-medium ${choice1.status === 'ok' ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{choice1.status === 'ok' ? '✓' : '✗'} {choice1.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center cursor-pointer px-4 py-3 border-2 rounded-lg transition hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value={choice2.value}
|
||||||
|
checked={value === choice2.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
onSave?.()
|
||||||
|
}}
|
||||||
|
className="mr-3"
|
||||||
|
/>
|
||||||
|
<span className={`font-medium ${choice2.status === 'ok' ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{choice2.status === 'ok' ? '✓' : '✗'} {choice2.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SINGLE CHOICE (selección única)
|
||||||
|
if (questionType === 'single_choice' && config.choices) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.choices.map((choice, idx) => (
|
||||||
|
<label
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center cursor-pointer px-4 py-3 border-2 rounded-lg transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value={choice.value}
|
||||||
|
checked={value === choice.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
onSave?.()
|
||||||
|
}}
|
||||||
|
className="mr-3"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 font-medium">{choice.label}</span>
|
||||||
|
{choice.points > 0 && (
|
||||||
|
<span className="text-sm text-blue-600">+{choice.points} pts</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{config.allow_other && (
|
||||||
|
<div className="pl-7">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="__other__"
|
||||||
|
checked={value && !config.choices.find(c => c.value === value)}
|
||||||
|
onChange={(e) => onChange('')}
|
||||||
|
className="mr-3"
|
||||||
|
/>
|
||||||
|
<span>Otro:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value && !config.choices.find(c => c.value === value) ? value : ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onSave}
|
||||||
|
className="ml-2 flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Especificar..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MULTIPLE CHOICE (selección múltiple)
|
||||||
|
if (questionType === 'multiple_choice' && config.choices) {
|
||||||
|
const selectedValues = value ? (Array.isArray(value) ? value : value.split(',')) : []
|
||||||
|
|
||||||
|
const handleToggle = (choiceValue) => {
|
||||||
|
let newValues
|
||||||
|
if (selectedValues.includes(choiceValue)) {
|
||||||
|
newValues = selectedValues.filter(v => v !== choiceValue)
|
||||||
|
} else {
|
||||||
|
newValues = [...selectedValues, choiceValue]
|
||||||
|
}
|
||||||
|
onChange(newValues.join(','))
|
||||||
|
onSave?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.choices.map((choice, idx) => (
|
||||||
|
<label
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center cursor-pointer px-4 py-3 border-2 rounded-lg transition hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedValues.includes(choice.value)}
|
||||||
|
onChange={() => handleToggle(choice.value)}
|
||||||
|
className="mr-3 w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 font-medium">{choice.label}</span>
|
||||||
|
{choice.points > 0 && (
|
||||||
|
<span className="text-sm text-blue-600">+{choice.points} pts</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCALE (escala numérica)
|
||||||
|
if (questionType === 'scale') {
|
||||||
|
const min = config.min || 1
|
||||||
|
const max = config.max || 5
|
||||||
|
const step = config.step || 1
|
||||||
|
const labels = config.labels || {}
|
||||||
|
|
||||||
|
const options = []
|
||||||
|
for (let i = min; i <= max; i += step) {
|
||||||
|
options.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||||
|
{labels.min && <span>{labels.min}</span>}
|
||||||
|
{labels.max && <span>{labels.max}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-center">
|
||||||
|
{options.map(num => (
|
||||||
|
<button
|
||||||
|
key={num}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(String(num))
|
||||||
|
onSave?.()
|
||||||
|
}}
|
||||||
|
className={`w-12 h-12 rounded-full font-bold transition ${
|
||||||
|
value === String(num)
|
||||||
|
? 'bg-blue-600 text-white scale-110'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
{value && (
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 rounded-lg">
|
||||||
|
<span className="text-sm text-gray-600">Seleccionado:</span>
|
||||||
|
<span className="font-bold text-blue-600 text-lg">{value}</span>
|
||||||
|
<span className="text-sm text-gray-600">/ {max}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEXT (texto libre)
|
||||||
|
if (questionType === 'text') {
|
||||||
|
const multiline = config.multiline !== false
|
||||||
|
const maxLength = config.max_length || 500
|
||||||
|
|
||||||
|
if (multiline) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onSave}
|
||||||
|
maxLength={maxLength}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Ingrese su respuesta..."
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-500 mt-1 text-right">
|
||||||
|
{(value?.length || 0)} / {maxLength} caracteres
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onSave}
|
||||||
|
maxLength={maxLength}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Ingrese su respuesta..."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NUMBER (valor numérico)
|
||||||
|
if (questionType === 'number') {
|
||||||
|
const min = config.min ?? 0
|
||||||
|
const max = config.max ?? 100
|
||||||
|
const unit = config.unit || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onSave}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step="any"
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder={`${min} - ${max}`}
|
||||||
|
/>
|
||||||
|
{unit && <span className="text-gray-600 font-medium">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DATE (fecha)
|
||||||
|
if (questionType === 'date') {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
onSave?.()
|
||||||
|
}}
|
||||||
|
min={config.min_date}
|
||||||
|
max={config.max_date}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TIME (hora)
|
||||||
|
if (questionType === 'time') {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
onSave?.()
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHOTO_ONLY (solo foto, sin campo de respuesta)
|
||||||
|
if (questionType === 'photo_only') {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
📸 Esta pregunta solo requiere fotografías. Adjunta las imágenes en la sección de fotos abajo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI_ASSISTANT (Chat con asistente)
|
||||||
|
if (questionType === 'ai_assistant') {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-gradient-to-r from-purple-50 to-blue-50 border-2 border-purple-200 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-2xl">💬</span>
|
||||||
|
<h4 className="font-semibold text-purple-900">Asistente Disponible</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-purple-700 mb-2">
|
||||||
|
Haz clic en el botón "💬 Consultar Asistente" debajo para abrir el chat.
|
||||||
|
El asistente ha analizado las fotos anteriores y está listo para ayudarte.
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-purple-600 bg-white/50 rounded px-2 py-1">
|
||||||
|
ℹ️ No requiere respuesta manual - el chat se guarda automáticamente
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback para tipos desconocidos
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
⚠️ Tipo de pregunta no reconocido: <code>{questionType}</code>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onSave}
|
||||||
|
className="mt-2 w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
placeholder="Respuesta de texto libre..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuestionAnswerInput
|
||||||
658
frontend/src/QuestionTypeEditor.jsx
Normal file
658
frontend/src/QuestionTypeEditor.jsx
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor de Tipos de Preguntas Configurables (estilo Google Forms)
|
||||||
|
*
|
||||||
|
* Tipos soportados:
|
||||||
|
* - boolean: Dos opciones personalizables
|
||||||
|
* - single_choice: Selección única con N opciones
|
||||||
|
* - multiple_choice: Selección múltiple
|
||||||
|
* - scale: Escala numérica
|
||||||
|
* - text: Texto libre
|
||||||
|
* - number: Valor numérico
|
||||||
|
* - date: Fecha
|
||||||
|
* - time: Hora
|
||||||
|
*/
|
||||||
|
|
||||||
|
const QUESTION_TYPES = [
|
||||||
|
{ value: 'boolean', label: '✓✗ Booleana (2 opciones)', icon: '🔘' },
|
||||||
|
{ value: 'single_choice', label: '◎ Selección Única', icon: '⚪' },
|
||||||
|
{ value: 'multiple_choice', label: '☑ Selección Múltiple', icon: '✅' },
|
||||||
|
{ value: 'scale', label: '⭐ Escala Numérica', icon: '📊' },
|
||||||
|
{ value: 'text', label: '📝 Texto Libre', icon: '✏️' },
|
||||||
|
{ value: 'number', label: '🔢 Número', icon: '#️⃣' },
|
||||||
|
{ value: 'date', label: '📅 Fecha', icon: '📆' },
|
||||||
|
{ value: 'time', label: '🕐 Hora', icon: '⏰' },
|
||||||
|
{ value: 'photo_only', label: '📸 Solo Fotografía', icon: '📷' },
|
||||||
|
{ value: 'ai_assistant', label: '🤖 Asistente (Chat)', icon: '💬' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'ok', label: 'OK (Verde)', color: 'green' },
|
||||||
|
{ value: 'warning', label: 'Advertencia (Amarillo)', color: 'yellow' },
|
||||||
|
{ value: 'critical', label: 'Crítico (Rojo)', color: 'red' },
|
||||||
|
{ value: 'info', label: 'Informativo (Azul)', color: 'blue' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Plantillas predefinidas para tipos booleanos
|
||||||
|
const BOOLEAN_TEMPLATES = [
|
||||||
|
{
|
||||||
|
name: 'Pasa/Falla',
|
||||||
|
choices: [
|
||||||
|
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||||||
|
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sí/No',
|
||||||
|
choices: [
|
||||||
|
{ value: 'yes', label: 'Sí', points: 1, status: 'ok' },
|
||||||
|
{ value: 'no', label: 'No', points: 0, status: 'critical' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Bueno/Malo',
|
||||||
|
choices: [
|
||||||
|
{ value: 'good', label: 'Bueno', points: 1, status: 'ok' },
|
||||||
|
{ value: 'bad', label: 'Malo', points: 0, status: 'critical' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Aprobado/Rechazado',
|
||||||
|
choices: [
|
||||||
|
{ value: 'approved', label: 'Aprobado', points: 1, status: 'ok' },
|
||||||
|
{ value: 'rejected', label: 'Rechazado', points: 0, status: 'critical' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Funciona/No Funciona',
|
||||||
|
choices: [
|
||||||
|
{ value: 'works', label: 'Funciona', points: 1, status: 'ok' },
|
||||||
|
{ value: 'not_works', label: 'No Funciona', points: 0, status: 'critical' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Personalizado',
|
||||||
|
choices: [
|
||||||
|
{ value: 'option1', label: 'Opción 1', points: 1, status: 'ok' },
|
||||||
|
{ value: 'option2', label: 'Opción 2', points: 0, status: 'critical' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function QuestionTypeEditor({ value, onChange, maxPoints = 1 }) {
|
||||||
|
const [config, setConfig] = useState(value || {
|
||||||
|
type: 'boolean',
|
||||||
|
choices: BOOLEAN_TEMPLATES[0].choices
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value && value.type) {
|
||||||
|
setConfig(value)
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleTypeChange = (newType) => {
|
||||||
|
let newConfig = { type: newType }
|
||||||
|
|
||||||
|
// Inicializar con valores por defecto según el tipo
|
||||||
|
switch (newType) {
|
||||||
|
case 'boolean':
|
||||||
|
newConfig.choices = [...BOOLEAN_TEMPLATES[0].choices]
|
||||||
|
break
|
||||||
|
case 'single_choice':
|
||||||
|
case 'multiple_choice':
|
||||||
|
newConfig.choices = [
|
||||||
|
{ value: 'option1', label: 'Opción 1', status: 'ok' },
|
||||||
|
{ value: 'option2', label: 'Opción 2', status: 'warning' },
|
||||||
|
{ value: 'option3', label: 'Opción 3', status: 'critical' }
|
||||||
|
]
|
||||||
|
newConfig.allow_other = false
|
||||||
|
break
|
||||||
|
case 'scale':
|
||||||
|
newConfig.min = 1
|
||||||
|
newConfig.max = 5
|
||||||
|
newConfig.step = 1
|
||||||
|
newConfig.labels = { min: 'Muy malo', max: 'Excelente' }
|
||||||
|
break
|
||||||
|
case 'text':
|
||||||
|
newConfig.multiline = true
|
||||||
|
newConfig.max_length = 500
|
||||||
|
break
|
||||||
|
case 'number':
|
||||||
|
newConfig.min = 0
|
||||||
|
newConfig.max = 100
|
||||||
|
newConfig.unit = ''
|
||||||
|
break
|
||||||
|
case 'date':
|
||||||
|
newConfig.min_date = null
|
||||||
|
newConfig.max_date = null
|
||||||
|
break
|
||||||
|
case 'time':
|
||||||
|
newConfig.format = '24h'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(newConfig)
|
||||||
|
onChange(newConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateConfig = (updates) => {
|
||||||
|
const newConfig = { ...config, ...updates }
|
||||||
|
setConfig(newConfig)
|
||||||
|
onChange(newConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateChoice = (index, field, value) => {
|
||||||
|
const newChoices = [...config.choices]
|
||||||
|
newChoices[index] = { ...newChoices[index], [field]: value }
|
||||||
|
updateConfig({ choices: newChoices })
|
||||||
|
}
|
||||||
|
|
||||||
|
const addChoice = () => {
|
||||||
|
const newChoices = [...config.choices, {
|
||||||
|
value: `option${config.choices.length + 1}`,
|
||||||
|
label: `Opción ${config.choices.length + 1}`,
|
||||||
|
status: 'ok'
|
||||||
|
}]
|
||||||
|
updateConfig({ choices: newChoices })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeChoice = (index) => {
|
||||||
|
if (config.type === 'boolean' && config.choices.length <= 2) {
|
||||||
|
alert('Las preguntas booleanas deben tener exactamente 2 opciones')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newChoices = config.choices.filter((_, i) => i !== index)
|
||||||
|
updateConfig({ choices: newChoices })
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyBooleanTemplate = (template) => {
|
||||||
|
updateConfig({ choices: [...template.choices] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Selector de Tipo de Pregunta */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Tipo de Pregunta
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
{QUESTION_TYPES.map(type => (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTypeChange(type.value)}
|
||||||
|
className={`p-3 border-2 rounded-lg text-left transition ${
|
||||||
|
config.type === type.value
|
||||||
|
? 'border-purple-600 bg-purple-50'
|
||||||
|
: 'border-gray-300 hover:border-purple-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-2xl mb-1">{type.icon}</div>
|
||||||
|
<div className="text-xs font-medium">{type.label}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Opciones Globales */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-blue-900 mb-3 text-sm">⚙️ Opciones Generales</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Checkbox para campo de observaciones */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.show_observations !== false}
|
||||||
|
onChange={(e) => updateConfig({ show_observations: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
📝 Mostrar campo de observaciones al mecánico
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 ml-6">
|
||||||
|
Si está marcado, el mecánico podrá agregar notas adicionales en esta pregunta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuración específica según tipo */}
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
{/* BOOLEAN */}
|
||||||
|
{config.type === 'boolean' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Plantilla Predefinida
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{BOOLEAN_TEMPLATES.map((template, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyBooleanTemplate(template)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg hover:border-purple-500 hover:bg-purple-50 text-sm transition"
|
||||||
|
>
|
||||||
|
{template.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{config.choices?.map((choice, idx) => (
|
||||||
|
<div key={idx} className="bg-white border border-gray-300 rounded-lg p-3">
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">
|
||||||
|
Opción {idx + 1}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={choice.label}
|
||||||
|
onChange={(e) => updateChoice(idx, 'label', e.target.value)}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded mb-2 text-sm"
|
||||||
|
placeholder="Texto de la opción"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Estado (Determina Puntuación)</label>
|
||||||
|
<select
|
||||||
|
value={choice.status || 'ok'}
|
||||||
|
onChange={(e) => updateChoice(idx, 'status', e.target.value)}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
|
||||||
|
>
|
||||||
|
<option value="ok">✓ OK (1pt)</option>
|
||||||
|
<option value="warning">⚠ Advertencia (0.5pt)</option>
|
||||||
|
<option value="critical">✗ Crítico (0pt)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SINGLE CHOICE / MULTIPLE CHOICE */}
|
||||||
|
{(config.type === 'single_choice' || config.type === 'multiple_choice') && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
Opciones ({config.choices?.length || 0})
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addChoice}
|
||||||
|
className="px-3 py-1 bg-purple-600 text-white rounded text-sm hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
+ Agregar Opción
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.choices?.map((choice, idx) => (
|
||||||
|
<div key={idx} className="flex gap-2 items-start bg-white border border-gray-300 rounded-lg p-2">
|
||||||
|
<div className="flex-shrink-0 mt-2">
|
||||||
|
{config.type === 'single_choice' ? '⚪' : '☑️'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 grid grid-cols-3 gap-2">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={choice.label}
|
||||||
|
onChange={(e) => updateChoice(idx, 'label', e.target.value)}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
|
||||||
|
placeholder={`Opción ${idx + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={choice.status || 'ok'}
|
||||||
|
onChange={(e) => updateChoice(idx, 'status', e.target.value)}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
|
||||||
|
title="Define la puntuación: OK=1pt, Advertencia=0.5pt, Crítico=0pt"
|
||||||
|
>
|
||||||
|
<option value="ok">✓ OK (1pt)</option>
|
||||||
|
<option value="warning">⚠ Advertencia (0.5pt)</option>
|
||||||
|
<option value="critical">✗ Crítico (0pt)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeChoice(idx)}
|
||||||
|
className="flex-shrink-0 px-2 py-1 text-red-600 hover:bg-red-50 rounded"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="allow_other"
|
||||||
|
checked={config.allow_other || false}
|
||||||
|
onChange={(e) => updateConfig({ allow_other: e.target.checked })}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="allow_other" className="text-sm text-gray-700">
|
||||||
|
Permitir opción "Otro" con texto libre
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SCALE */}
|
||||||
|
{config.type === 'scale' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Mínimo</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.min || 1}
|
||||||
|
onChange={(e) => updateConfig({ min: parseInt(e.target.value) || 1 })}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Máximo</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.max || 5}
|
||||||
|
onChange={(e) => updateConfig({ max: parseInt(e.target.value) || 5 })}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Incremento</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.step || 1}
|
||||||
|
onChange={(e) => updateConfig({ step: parseInt(e.target.value) || 1 })}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Etiqueta Mínimo</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.labels?.min || ''}
|
||||||
|
onChange={(e) => updateConfig({
|
||||||
|
labels: { ...config.labels, min: e.target.value }
|
||||||
|
})}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
placeholder="Ej: Muy malo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Etiqueta Máximo</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.labels?.max || ''}
|
||||||
|
onChange={(e) => updateConfig({
|
||||||
|
labels: { ...config.labels, max: e.target.value }
|
||||||
|
})}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
placeholder="Ej: Excelente"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded p-3">
|
||||||
|
<p className="text-xs text-blue-800">
|
||||||
|
Vista previa: {config.min} {config.labels?.min} ⭐⭐⭐⭐⭐ {config.max} {config.labels?.max}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TEXT */}
|
||||||
|
{config.type === 'text' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="multiline"
|
||||||
|
checked={config.multiline !== false}
|
||||||
|
onChange={(e) => updateConfig({ multiline: e.target.checked })}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="multiline" className="text-sm text-gray-700">
|
||||||
|
Permitir múltiples líneas
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">
|
||||||
|
Longitud máxima (caracteres)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.max_length || 500}
|
||||||
|
onChange={(e) => updateConfig({ max_length: parseInt(e.target.value) || 500 })}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
min="1"
|
||||||
|
max="5000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* NUMBER */}
|
||||||
|
{config.type === 'number' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Mínimo</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.min ?? 0}
|
||||||
|
onChange={(e) => updateConfig({ min: parseFloat(e.target.value) })}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Máximo</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.max ?? 100}
|
||||||
|
onChange={(e) => updateConfig({ max: parseFloat(e.target.value) })}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Unidad</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.unit || ''}
|
||||||
|
onChange={(e) => updateConfig({ unit: e.target.value })}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
placeholder="Ej: km, kg, °C"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DATE */}
|
||||||
|
{config.type === 'date' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Fecha mínima (opcional)</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={config.min_date || ''}
|
||||||
|
onChange={(e) => updateConfig({ min_date: e.target.value })}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Fecha máxima (opcional)</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={config.max_date || ''}
|
||||||
|
onChange={(e) => updateConfig({ max_date: e.target.value })}
|
||||||
|
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TIME */}
|
||||||
|
{config.type === 'time' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-2">Formato</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="12h"
|
||||||
|
checked={config.format === '12h'}
|
||||||
|
onChange={(e) => updateConfig({ format: e.target.value })}
|
||||||
|
className="border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">12 horas (AM/PM)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="24h"
|
||||||
|
checked={config.format !== '12h'}
|
||||||
|
onChange={(e) => updateConfig({ format: e.target.value })}
|
||||||
|
className="border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">24 horas</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI ASSISTANT (CHAT) */}
|
||||||
|
{config.type === 'ai_assistant' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-3xl">💬</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-blue-900 mb-1">Asistente Conversacional</h4>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
El mecánico podrá chatear con el asistente usando fotos de preguntas anteriores como contexto.
|
||||||
|
Configura qué preguntas anteriores usar y el comportamiento del asistente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt del asistente */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
🎯 Prompt del Asistente (Rol y Comportamiento)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={config.assistant_prompt || ''}
|
||||||
|
onChange={(e) => updateConfig({ assistant_prompt: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||||
|
rows="6"
|
||||||
|
placeholder="Ejemplo: Eres un experto mecánico especializado en sistemas de frenos. Ayuda al mecánico a diagnosticar problemas basándote en las fotos que has visto. Sé directo y técnico. Si ves algo anormal en las fotos, menciónalo proactivamente."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Define cómo debe comportarse el asistente: su rol, tono, especialidad, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preguntas de contexto */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
📸 Preguntas Anteriores para Contexto
|
||||||
|
</label>
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
|
||||||
|
<p className="text-xs text-yellow-800">
|
||||||
|
<strong>💡 Cómo funciona:</strong> El asistente verá las fotos de las preguntas que selecciones abajo.
|
||||||
|
Elige preguntas cuyas fotos sean relevantes para el diagnóstico (ej: si es asistente de frenos, selecciona preguntas sobre pastillas, discos, líquido de frenos, etc.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={config.context_questions || ''}
|
||||||
|
onChange={(e) => updateConfig({ context_questions: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder="IDs de preguntas separados por comas. Ejemplo: 5,8,12,15
|
||||||
|
Dejar vacío para usar TODAS las preguntas anteriores con fotos."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Especifica los IDs de preguntas anteriores cuyas fotos debe analizar el asistente, o déjalo vacío para usar todas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instrucciones adicionales */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
📋 Instrucciones Adicionales (Opcional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={config.assistant_instructions || ''}
|
||||||
|
onChange={(e) => updateConfig({ assistant_instructions: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Instrucciones específicas adicionales.
|
||||||
|
Ejemplo:
|
||||||
|
- Si detectas pastillas con menos de 3mm, recomienda cambio inmediato
|
||||||
|
- Siempre verifica si hay fugas de líquido
|
||||||
|
- Menciona el código OBD2 si es relevante"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Reglas o criterios específicos que el asistente debe seguir al dar consejos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuración de respuestas */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
💬 Mensajes Máximos
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
value={config.max_messages || 20}
|
||||||
|
onChange={(e) => updateConfig({ max_messages: parseInt(e.target.value) })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Límite de mensajes en el chat
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
📏 Longitud de Respuesta
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={config.response_length || 'medium'}
|
||||||
|
onChange={(e) => updateConfig({ response_length: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="short">Corta (concisa)</option>
|
||||||
|
<option value="medium">Media (balanceada)</option>
|
||||||
|
<option value="long">Larga (detallada)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuestionTypeEditor
|
||||||
@@ -1,19 +1,35 @@
|
|||||||
export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, setSidebarOpen, onLogout }) {
|
export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, setSidebarOpen, onLogout }) {
|
||||||
return (
|
return (
|
||||||
<aside className={`bg-gradient-to-b from-gray-900 via-indigo-950 to-purple-950 text-white transition-all duration-300 ${sidebarOpen ? 'w-64' : 'w-16'} flex flex-col fixed h-full z-10 shadow-2xl`}>
|
<>
|
||||||
|
{/* Overlay para cerrar sidebar en móvil */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className={`bg-gradient-to-b from-gray-900 via-indigo-950 to-purple-950 text-white transition-all duration-300 flex flex-col fixed h-full shadow-2xl
|
||||||
|
${sidebarOpen ? 'w-64' : 'w-16'}
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||||
|
z-30 lg:z-10
|
||||||
|
`}>
|
||||||
{/* Sidebar Header */}
|
{/* Sidebar Header */}
|
||||||
<div className={`p-4 flex items-center ${sidebarOpen ? 'justify-between' : 'justify-center'} border-b border-indigo-800/50`}>
|
<div className={`p-4 flex items-center ${sidebarOpen ? 'justify-between' : 'justify-center'} border-b border-indigo-800/50`}>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-lg flex items-center justify-center">
|
<img
|
||||||
<span className="text-white font-bold text-lg">S</span>
|
src="/ayutec_logo.png"
|
||||||
</div>
|
alt="Ayutec"
|
||||||
<h2 className="text-xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Syntria</h2>
|
className="w-8 h-8 object-contain bg-white rounded-lg p-1"
|
||||||
|
/>
|
||||||
|
<h2 className="text-xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Ayutec</h2>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
className="p-2 rounded-lg hover:bg-indigo-800/50 transition"
|
className="p-2 rounded-lg hover:bg-indigo-800/50 transition lg:block"
|
||||||
title={sidebarOpen ? 'Ocultar sidebar' : 'Mostrar sidebar'}
|
title={sidebarOpen ? 'Ocultar sidebar' : 'Mostrar sidebar'}
|
||||||
>
|
>
|
||||||
{sidebarOpen ? '☰' : '☰'}
|
{sidebarOpen ? '☰' : '☰'}
|
||||||
@@ -122,6 +138,27 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
|
|||||||
|
|
||||||
{/* User Info */}
|
{/* User Info */}
|
||||||
<div className="p-4 border-t border-indigo-800/50">
|
<div className="p-4 border-t border-indigo-800/50">
|
||||||
|
{/* Versión */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="mb-3 px-2 py-1.5 bg-indigo-900/30 rounded-lg border border-indigo-700/30">
|
||||||
|
<a
|
||||||
|
href="https://ayutec.es"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/ayutec_logo.webp"
|
||||||
|
alt="Ayutec"
|
||||||
|
className="w-10 h-10 object-contain bg-white rounded p-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-indigo-300 font-medium hover:text-indigo-200">
|
||||||
|
Ayutec v1.3.8
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={`flex items-center gap-3 ${!sidebarOpen && 'justify-center'}`}>
|
<div className={`flex items-center gap-3 ${!sidebarOpen && 'justify-center'}`}>
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full flex items-center justify-center text-white font-bold flex-shrink-0 shadow-lg">
|
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full flex items-center justify-center text-white font-bold flex-shrink-0 shadow-lg">
|
||||||
{user.username.charAt(0).toUpperCase()}
|
{user.username.charAt(0).toUpperCase()}
|
||||||
@@ -150,5 +187,6 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
41
frontend/update-version.ps1
Normal file
41
frontend/update-version.ps1
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Script para actualizar la versión del frontend y service worker automáticamente
|
||||||
|
|
||||||
|
Write-Host "🔄 Actualizando versión del frontend..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Leer package.json
|
||||||
|
$packageJson = Get-Content "package.json" -Raw | ConvertFrom-Json
|
||||||
|
$currentVersion = $packageJson.version
|
||||||
|
Write-Host "📦 Versión actual: $currentVersion" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Separar versión en partes (major.minor.patch)
|
||||||
|
$versionParts = $currentVersion -split '\.'
|
||||||
|
$major = [int]$versionParts[0]
|
||||||
|
$minor = [int]$versionParts[1]
|
||||||
|
$patch = [int]$versionParts[2]
|
||||||
|
|
||||||
|
# Incrementar patch
|
||||||
|
$patch++
|
||||||
|
$newVersion = "$major.$minor.$patch"
|
||||||
|
|
||||||
|
Write-Host "✨ Nueva versión: $newVersion" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Actualizar package.json
|
||||||
|
$packageJsonContent = Get-Content "package.json" -Raw
|
||||||
|
$packageJsonContent = $packageJsonContent -replace """version"": ""$currentVersion""", """version"": ""$newVersion"""
|
||||||
|
Set-Content "package.json" -Value $packageJsonContent -NoNewline
|
||||||
|
|
||||||
|
Write-Host "✅ package.json actualizado" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Actualizar service-worker.js
|
||||||
|
$swPath = "public\service-worker.js"
|
||||||
|
$swContent = Get-Content $swPath -Raw
|
||||||
|
$swContent = $swContent -replace "ayutec-v$currentVersion", "ayutec-v$newVersion"
|
||||||
|
Set-Content $swPath -Value $swContent -NoNewline
|
||||||
|
|
||||||
|
Write-Host "✅ service-worker.js actualizado" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "🎉 Versión actualizada exitosamente a: $newVersion" -ForegroundColor Magenta
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📝 Recuerda hacer commit de los cambios:" -ForegroundColor Yellow
|
||||||
|
Write-Host " git add package.json public/service-worker.js" -ForegroundColor Gray
|
||||||
|
Write-Host " git commit -m 'chore: bump version to $newVersion'" -ForegroundColor Gray
|
||||||
45
gitUpdate.ps1
Normal file
45
gitUpdate.ps1
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
Clear-Host
|
||||||
|
|
||||||
|
# Crear archivo temporal para el mensaje
|
||||||
|
$tempFile = [System.IO.Path]::GetTempFileName()
|
||||||
|
|
||||||
|
Write-Host "Abriendo editor de texto..." -ForegroundColor Cyan
|
||||||
|
Write-Host "1. Pegue su mensaje de commit"
|
||||||
|
Write-Host "2. Guarde el archivo (Ctrl+S)"
|
||||||
|
Write-Host "3. Cierre el editor (Alt+F4 o X)" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Abrir notepad con el archivo temporal
|
||||||
|
notepad++.exe $tempFile | Out-Null
|
||||||
|
|
||||||
|
# Verificar que el archivo tenga contenido
|
||||||
|
if (-not (Test-Path $tempFile) -or (Get-Item $tempFile).Length -eq 0) {
|
||||||
|
Write-Host "No se ingreso ningun mensaje. Abortando..." -ForegroundColor Red
|
||||||
|
Remove-Item $tempFile -ErrorAction SilentlyContinue
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nAgregando archivos..."
|
||||||
|
git add .
|
||||||
|
|
||||||
|
Write-Host "Creando commit..."
|
||||||
|
git commit -F $tempFile
|
||||||
|
|
||||||
|
# Eliminar archivo temporal después del commit
|
||||||
|
Remove-Item $tempFile -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Write-Host "Haciendo push a la rama develop..."
|
||||||
|
$pushOutput = git push origin develop 2>&1
|
||||||
|
|
||||||
|
# Revisar si fallo la autenticacion
|
||||||
|
if ($pushOutput -match "Authentication failed" -or $pushOutput -match "Failed to authenticate") {
|
||||||
|
Write-Host "`nERROR: Fallo la autenticacion. Ejecutando git init para reconfigurar..." -ForegroundColor Red
|
||||||
|
|
||||||
|
git init
|
||||||
|
|
||||||
|
Write-Host "Intentando push nuevamente..."
|
||||||
|
git push origin develop
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nProceso finalizado."
|
||||||
|
|
||||||
41
lineas.ps1
Normal file
41
lineas.ps1
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$Fecha
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convertir fecha dd/mm/aaaa → yyyy-mm-dd
|
||||||
|
try {
|
||||||
|
$dateObj = [datetime]::ParseExact($Fecha, "dd/MM/yyyy", $null)
|
||||||
|
} catch {
|
||||||
|
Write-Host "❌ Formato inválido. Usa dd/mm/aaaa (ej: 05/12/2025)"
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
$FechaISO = $dateObj.ToString("yyyy-MM-dd")
|
||||||
|
|
||||||
|
$desde = "$FechaISO 00:00"
|
||||||
|
$hasta = "$FechaISO 23:59"
|
||||||
|
|
||||||
|
$added = 0
|
||||||
|
$removed = 0
|
||||||
|
|
||||||
|
git log --since="$desde" --until="$hasta" --pretty=tformat: --numstat |
|
||||||
|
ForEach-Object {
|
||||||
|
$cols = $_.Split()
|
||||||
|
|
||||||
|
# Saltar líneas vacías
|
||||||
|
if ($cols.Length -lt 2) { return }
|
||||||
|
|
||||||
|
# Si Git muestra "-", ignorar esta línea
|
||||||
|
if ($cols[0] -eq "-" -or $cols[1] -eq "-") { return }
|
||||||
|
|
||||||
|
# Sumar líneas
|
||||||
|
$added += [int]$cols[0]
|
||||||
|
$removed += [int]$cols[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Fecha ingresada: $Fecha"
|
||||||
|
Write-Host "Fecha usada (ISO): $FechaISO"
|
||||||
|
Write-Host "Líneas añadidas: $added"
|
||||||
|
Write-Host "Líneas eliminadas: $removed"
|
||||||
|
Write-Host "Total neto: " ($added - $removed)
|
||||||
28
migrations/add_ai_analysis_if_not_exists.sql
Normal file
28
migrations/add_ai_analysis_if_not_exists.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Migración: Asegurar que ai_analysis existe en la tabla answers
|
||||||
|
-- Fecha: 2025-11-26
|
||||||
|
-- Descripción: Agrega la columna ai_analysis si no existe (para guardar el resultado del análisis de IA)
|
||||||
|
|
||||||
|
-- Agregar columna ai_analysis si no existe
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'answers'
|
||||||
|
AND column_name = 'ai_analysis'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE answers ADD COLUMN ai_analysis JSONB;
|
||||||
|
COMMENT ON COLUMN answers.ai_analysis IS 'Resultado del análisis de IA: {status, observations, recommendation, confidence, model, provider}';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Verificar que la columna existe
|
||||||
|
SELECT
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
is_nullable,
|
||||||
|
column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'answers'
|
||||||
|
AND column_name = 'ai_analysis';
|
||||||
|
|
||||||
|
SELECT '✅ Columna ai_analysis verificada/creada en tabla answers' as status;
|
||||||
6
migrations/add_chat_history_to_answers.sql
Normal file
6
migrations/add_chat_history_to_answers.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Agregar campo chat_history a la tabla answers
|
||||||
|
-- Fecha: 2025-12-02
|
||||||
|
|
||||||
|
ALTER TABLE answers ADD COLUMN IF NOT EXISTS chat_history JSON;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN answers.chat_history IS 'Historial de conversación con AI Assistant para preguntas tipo ai_assistant';
|
||||||
26
migrations/add_checklist_permissions.sql
Normal file
26
migrations/add_checklist_permissions.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Migración: Agregar sistema de permisos por mecánico para checklists
|
||||||
|
-- Fecha: 2025-11-25
|
||||||
|
-- Descripción: Crea tabla intermedia para controlar qué mecánicos pueden usar cada checklist
|
||||||
|
|
||||||
|
-- Crear tabla de permisos checklist-mecánico
|
||||||
|
CREATE TABLE IF NOT EXISTS checklist_permissions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE,
|
||||||
|
mechanic_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- Constraint para evitar duplicados
|
||||||
|
UNIQUE(checklist_id, mechanic_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Crear índices para mejorar rendimiento
|
||||||
|
CREATE INDEX idx_checklist_permissions_checklist ON checklist_permissions(checklist_id);
|
||||||
|
CREATE INDEX idx_checklist_permissions_mechanic ON checklist_permissions(mechanic_id);
|
||||||
|
|
||||||
|
-- Comentarios para documentación
|
||||||
|
COMMENT ON TABLE checklist_permissions IS 'Control de acceso de mecánicos a checklists. Si no hay registros para un checklist, todos los mecánicos tienen acceso.';
|
||||||
|
COMMENT ON COLUMN checklist_permissions.checklist_id IS 'ID del checklist restringido';
|
||||||
|
COMMENT ON COLUMN checklist_permissions.mechanic_id IS 'ID del mecánico autorizado';
|
||||||
|
|
||||||
|
-- Verificar que la migración se ejecutó correctamente
|
||||||
|
SELECT 'Tabla checklist_permissions creada exitosamente' AS status;
|
||||||
16
migrations/add_employee_code_to_users.sql
Normal file
16
migrations/add_employee_code_to_users.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migración: Agregar campo employee_code (Nro Operario) a la tabla users
|
||||||
|
-- Fecha: 2025-11-26
|
||||||
|
-- Descripción: Agrega un campo opcional para almacenar el código de operario de otro sistema
|
||||||
|
|
||||||
|
-- Agregar columna employee_code a la tabla users
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS employee_code VARCHAR(50);
|
||||||
|
|
||||||
|
-- Comentario descriptivo
|
||||||
|
COMMENT ON COLUMN users.employee_code IS 'Número de operario - código de identificación de otro sistema';
|
||||||
|
|
||||||
|
-- Verificar que la columna se agregó correctamente
|
||||||
|
SELECT column_name, data_type, character_maximum_length
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'users'
|
||||||
|
AND column_name = 'employee_code';
|
||||||
153
migrations/add_flexible_question_types.sql
Normal file
153
migrations/add_flexible_question_types.sql
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
-- Migración: Sistema de tipos de preguntas configurables (estilo Google Forms)
|
||||||
|
-- Fecha: 2025-11-25
|
||||||
|
-- Descripción: Permite crear tipos de preguntas personalizables con opciones definidas por el usuario
|
||||||
|
|
||||||
|
-- PASO 1: Agregar comentario explicativo a la columna options
|
||||||
|
COMMENT ON COLUMN questions.options IS 'Configuración JSON de la pregunta con choices personalizables. Ejemplos:
|
||||||
|
Boolean: {"type": "boolean", "choices": [{"value": "yes", "label": "Sí", "points": 1, "status": "ok"}, {"value": "no", "label": "No", "points": 0, "status": "critical"}]}
|
||||||
|
Single Choice: {"type": "single_choice", "choices": [{"value": "excellent", "label": "Excelente", "points": 3}, {"value": "good", "label": "Bueno", "points": 2}]}
|
||||||
|
Multiple Choice: {"type": "multiple_choice", "choices": [{"value": "lights", "label": "Luces"}, {"value": "wipers", "label": "Limpiaparabrisas"}]}
|
||||||
|
Scale: {"type": "scale", "min": 1, "max": 5, "step": 1, "labels": {"min": "Muy malo", "max": "Excelente"}}';
|
||||||
|
|
||||||
|
-- PASO 2: Actualizar el comentario de la columna type
|
||||||
|
COMMENT ON COLUMN questions.type IS 'Tipo de pregunta:
|
||||||
|
- boolean: Dos opciones personalizables (ej: Sí/No, Pasa/Falla, Bueno/Malo)
|
||||||
|
- single_choice: Selección única con opciones personalizadas
|
||||||
|
- multiple_choice: Selección múltiple con opciones personalizadas
|
||||||
|
- scale: Escala numérica personalizable (1-5, 1-10, etc.)
|
||||||
|
- text: Texto libre
|
||||||
|
- number: Valor numérico
|
||||||
|
- date: Fecha
|
||||||
|
- time: Hora';
|
||||||
|
|
||||||
|
-- PASO 3: Migrar datos existentes de pass_fail y good_bad al nuevo formato
|
||||||
|
-- Actualizar preguntas tipo pass_fail
|
||||||
|
UPDATE questions
|
||||||
|
SET
|
||||||
|
type = 'boolean',
|
||||||
|
options = jsonb_build_object(
|
||||||
|
'type', 'boolean',
|
||||||
|
'choices', jsonb_build_array(
|
||||||
|
jsonb_build_object(
|
||||||
|
'value', 'pass',
|
||||||
|
'label', 'Pasa',
|
||||||
|
'points', points,
|
||||||
|
'status', 'ok'
|
||||||
|
),
|
||||||
|
jsonb_build_object(
|
||||||
|
'value', 'fail',
|
||||||
|
'label', 'Falla',
|
||||||
|
'points', 0,
|
||||||
|
'status', 'critical'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE type = 'pass_fail';
|
||||||
|
|
||||||
|
-- Actualizar preguntas tipo good_bad
|
||||||
|
UPDATE questions
|
||||||
|
SET
|
||||||
|
type = 'boolean',
|
||||||
|
options = jsonb_build_object(
|
||||||
|
'type', 'boolean',
|
||||||
|
'choices', jsonb_build_array(
|
||||||
|
jsonb_build_object(
|
||||||
|
'value', 'good',
|
||||||
|
'label', 'Bueno',
|
||||||
|
'points', points,
|
||||||
|
'status', 'ok'
|
||||||
|
),
|
||||||
|
jsonb_build_object(
|
||||||
|
'value', 'bad',
|
||||||
|
'label', 'Malo',
|
||||||
|
'points', 0,
|
||||||
|
'status', 'critical'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE type = 'good_bad';
|
||||||
|
|
||||||
|
-- Actualizar preguntas tipo good_bad_regular (3 opciones)
|
||||||
|
UPDATE questions
|
||||||
|
SET
|
||||||
|
type = 'single_choice',
|
||||||
|
options = jsonb_build_object(
|
||||||
|
'type', 'single_choice',
|
||||||
|
'choices', jsonb_build_array(
|
||||||
|
jsonb_build_object(
|
||||||
|
'value', 'good',
|
||||||
|
'label', 'Bueno',
|
||||||
|
'points', points,
|
||||||
|
'status', 'ok'
|
||||||
|
),
|
||||||
|
jsonb_build_object(
|
||||||
|
'value', 'regular',
|
||||||
|
'label', 'Regular',
|
||||||
|
'points', FLOOR(points / 2),
|
||||||
|
'status', 'warning'
|
||||||
|
),
|
||||||
|
jsonb_build_object(
|
||||||
|
'value', 'bad',
|
||||||
|
'label', 'Malo',
|
||||||
|
'points', 0,
|
||||||
|
'status', 'critical'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE type = 'good_bad_regular';
|
||||||
|
|
||||||
|
-- PASO 4: Actualizar preguntas de tipo text sin opciones
|
||||||
|
UPDATE questions
|
||||||
|
SET
|
||||||
|
options = jsonb_build_object(
|
||||||
|
'type', 'text',
|
||||||
|
'multiline', true,
|
||||||
|
'max_length', 500
|
||||||
|
)
|
||||||
|
WHERE type = 'text' AND (options IS NULL OR options::text = '{}');
|
||||||
|
|
||||||
|
-- PASO 5: Crear función helper para validar estructura de options
|
||||||
|
CREATE OR REPLACE FUNCTION validate_question_options()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Validar que options tenga el campo type
|
||||||
|
IF NEW.options IS NOT NULL AND NOT (NEW.options ? 'type') THEN
|
||||||
|
RAISE EXCEPTION 'El campo options debe contener una propiedad "type"';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validar que boolean y single_choice tengan choices
|
||||||
|
IF NEW.type IN ('boolean', 'single_choice', 'multiple_choice') THEN
|
||||||
|
IF NEW.options IS NULL OR NOT (NEW.options ? 'choices') THEN
|
||||||
|
RAISE EXCEPTION 'Las preguntas de tipo % requieren un array "choices" en options', NEW.type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validar que boolean tenga exactamente 2 opciones
|
||||||
|
IF NEW.type = 'boolean' AND jsonb_array_length(NEW.options->'choices') != 2 THEN
|
||||||
|
RAISE EXCEPTION 'Las preguntas de tipo boolean deben tener exactamente 2 opciones';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- PASO 6: Crear trigger para validación (opcional, comentado por si causa problemas)
|
||||||
|
-- DROP TRIGGER IF EXISTS validate_options_trigger ON questions;
|
||||||
|
-- CREATE TRIGGER validate_options_trigger
|
||||||
|
-- BEFORE INSERT OR UPDATE ON questions
|
||||||
|
-- FOR EACH ROW
|
||||||
|
-- EXECUTE FUNCTION validate_question_options();
|
||||||
|
|
||||||
|
-- PASO 7: Índice para búsquedas por tipo de pregunta
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_type ON questions(type);
|
||||||
|
|
||||||
|
-- Verificación
|
||||||
|
SELECT
|
||||||
|
type,
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(CASE WHEN options IS NOT NULL THEN 1 END) as with_options
|
||||||
|
FROM questions
|
||||||
|
GROUP BY type
|
||||||
|
ORDER BY type;
|
||||||
|
|
||||||
|
SELECT 'Migración de tipos de preguntas flexibles completada exitosamente' AS status;
|
||||||
13
migrations/add_generate_pdf_to_checklists.sql
Normal file
13
migrations/add_generate_pdf_to_checklists.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Agregar campo generate_pdf a la tabla checklists
|
||||||
|
-- Este campo controla si se genera PDF automáticamente al completar una inspección
|
||||||
|
|
||||||
|
ALTER TABLE checklists
|
||||||
|
ADD COLUMN IF NOT EXISTS generate_pdf BOOLEAN DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- Actualizar checklists existentes para que generen PDF por defecto
|
||||||
|
UPDATE checklists
|
||||||
|
SET generate_pdf = TRUE
|
||||||
|
WHERE generate_pdf IS NULL;
|
||||||
|
|
||||||
|
-- Comentario para documentación
|
||||||
|
COMMENT ON COLUMN checklists.generate_pdf IS 'Controla si se genera PDF automáticamente al completar inspección';
|
||||||
39
migrations/add_inspection_audit_log.sql
Normal file
39
migrations/add_inspection_audit_log.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
-- Migración: Agregar sistema de auditoría para edición de inspecciones
|
||||||
|
-- Fecha: 2025-11-25
|
||||||
|
-- Descripción: Crea tabla de auditoría para rastrear todos los cambios en inspecciones y respuestas
|
||||||
|
|
||||||
|
-- Crear tabla de auditoría
|
||||||
|
CREATE TABLE IF NOT EXISTS inspection_audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
inspection_id INTEGER NOT NULL REFERENCES inspections(id) ON DELETE CASCADE,
|
||||||
|
answer_id INTEGER REFERENCES answers(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
action VARCHAR(50) NOT NULL, -- created, updated, deleted, status_changed
|
||||||
|
entity_type VARCHAR(50) NOT NULL, -- inspection, answer
|
||||||
|
field_name VARCHAR(100), -- Campo modificado
|
||||||
|
old_value TEXT, -- Valor anterior
|
||||||
|
new_value TEXT, -- Valor nuevo
|
||||||
|
comment TEXT, -- Comentario del cambio
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Crear índices para mejorar rendimiento
|
||||||
|
CREATE INDEX idx_audit_log_inspection ON inspection_audit_log(inspection_id);
|
||||||
|
CREATE INDEX idx_audit_log_answer ON inspection_audit_log(answer_id);
|
||||||
|
CREATE INDEX idx_audit_log_user ON inspection_audit_log(user_id);
|
||||||
|
CREATE INDEX idx_audit_log_created_at ON inspection_audit_log(created_at DESC);
|
||||||
|
|
||||||
|
-- Comentarios para documentación
|
||||||
|
COMMENT ON TABLE inspection_audit_log IS 'Registro de auditoría de cambios en inspecciones y respuestas. Registra quién, cuándo y qué cambió.';
|
||||||
|
COMMENT ON COLUMN inspection_audit_log.action IS 'Tipo de acción: created, updated, deleted, status_changed';
|
||||||
|
COMMENT ON COLUMN inspection_audit_log.entity_type IS 'Tipo de entidad modificada: inspection, answer';
|
||||||
|
COMMENT ON COLUMN inspection_audit_log.field_name IS 'Nombre del campo que fue modificado';
|
||||||
|
COMMENT ON COLUMN inspection_audit_log.old_value IS 'Valor anterior del campo';
|
||||||
|
COMMENT ON COLUMN inspection_audit_log.new_value IS 'Valor nuevo del campo';
|
||||||
|
COMMENT ON COLUMN inspection_audit_log.comment IS 'Comentario del administrador sobre por qué realizó el cambio';
|
||||||
|
|
||||||
|
-- Verificar que la migración se ejecutó correctamente
|
||||||
|
SELECT 'Tabla inspection_audit_log creada exitosamente' AS status;
|
||||||
|
|
||||||
|
-- Opcional: Ver estructura de la tabla
|
||||||
|
\d inspection_audit_log
|
||||||
23
migrations/add_mechanic_employee_code_to_inspections.sql
Normal file
23
migrations/add_mechanic_employee_code_to_inspections.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- Migración: Agregar campo mechanic_employee_code a la tabla inspections
|
||||||
|
-- Fecha: 2025-11-26
|
||||||
|
-- Descripción: Agrega un campo para almacenar el código de operario del mecánico que realiza la inspección
|
||||||
|
|
||||||
|
-- Agregar columna mechanic_employee_code a la tabla inspections
|
||||||
|
ALTER TABLE inspections
|
||||||
|
ADD COLUMN IF NOT EXISTS mechanic_employee_code VARCHAR(50);
|
||||||
|
|
||||||
|
-- Comentario descriptivo
|
||||||
|
COMMENT ON COLUMN inspections.mechanic_employee_code IS 'Código de operario del mecánico que realiza la inspección (copiado del perfil de usuario)';
|
||||||
|
|
||||||
|
-- Actualizar inspecciones existentes con el employee_code del mecánico correspondiente
|
||||||
|
UPDATE inspections i
|
||||||
|
SET mechanic_employee_code = u.employee_code
|
||||||
|
FROM users u
|
||||||
|
WHERE i.mechanic_id = u.id
|
||||||
|
AND i.mechanic_employee_code IS NULL;
|
||||||
|
|
||||||
|
-- Verificar que la columna se agregó correctamente
|
||||||
|
SELECT column_name, data_type, character_maximum_length
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'inspections'
|
||||||
|
AND column_name = 'mechanic_employee_code';
|
||||||
172
migrations/add_nested_subquestions.sql
Normal file
172
migrations/add_nested_subquestions.sql
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
-- Migración: Subpreguntas anidadas hasta 5 niveles
|
||||||
|
-- Fecha: 2025-11-25
|
||||||
|
-- Descripción: Agrega soporte y validación para subpreguntas anidadas hasta 5 niveles de profundidad
|
||||||
|
|
||||||
|
-- Agregar columna para tracking de nivel (opcional pero útil)
|
||||||
|
ALTER TABLE questions
|
||||||
|
ADD COLUMN IF NOT EXISTS depth_level INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- Comentarios
|
||||||
|
COMMENT ON COLUMN questions.parent_question_id IS 'ID de la pregunta padre. NULL = pregunta principal. Soporta anidamiento hasta 5 niveles.';
|
||||||
|
COMMENT ON COLUMN questions.depth_level IS 'Nivel de profundidad: 0=principal, 1-5=subpreguntas anidadas';
|
||||||
|
|
||||||
|
-- Función para calcular profundidad de una pregunta
|
||||||
|
CREATE OR REPLACE FUNCTION calculate_question_depth(question_id INTEGER)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
current_parent_id INTEGER;
|
||||||
|
depth INTEGER := 0;
|
||||||
|
max_iterations INTEGER := 10; -- Protección contra loops infinitos
|
||||||
|
BEGIN
|
||||||
|
-- Obtener el parent_id de la pregunta
|
||||||
|
SELECT parent_question_id INTO current_parent_id
|
||||||
|
FROM questions
|
||||||
|
WHERE id = question_id;
|
||||||
|
|
||||||
|
-- Si no tiene padre, es nivel 0
|
||||||
|
IF current_parent_id IS NULL THEN
|
||||||
|
RETURN 0;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Subir por la jerarquía contando niveles
|
||||||
|
WHILE current_parent_id IS NOT NULL AND depth < max_iterations LOOP
|
||||||
|
depth := depth + 1;
|
||||||
|
|
||||||
|
SELECT parent_question_id INTO current_parent_id
|
||||||
|
FROM questions
|
||||||
|
WHERE id = current_parent_id;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN depth;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función trigger para validar profundidad máxima
|
||||||
|
CREATE OR REPLACE FUNCTION validate_question_depth()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
calculated_depth INTEGER;
|
||||||
|
parent_depth INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Si no tiene padre, es nivel 0
|
||||||
|
IF NEW.parent_question_id IS NULL THEN
|
||||||
|
NEW.depth_level := 0;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validar que el padre existe y no es la misma pregunta
|
||||||
|
IF NEW.parent_question_id = NEW.id THEN
|
||||||
|
RAISE EXCEPTION 'Una pregunta no puede ser su propio padre';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Calcular profundidad del padre
|
||||||
|
SELECT depth_level INTO parent_depth
|
||||||
|
FROM questions
|
||||||
|
WHERE id = NEW.parent_question_id;
|
||||||
|
|
||||||
|
IF parent_depth IS NULL THEN
|
||||||
|
-- Si el padre no tiene depth_level, calcularlo
|
||||||
|
parent_depth := calculate_question_depth(NEW.parent_question_id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- La nueva pregunta es un nivel más profundo que su padre
|
||||||
|
calculated_depth := parent_depth + 1;
|
||||||
|
|
||||||
|
-- Validar que no excede 5 niveles
|
||||||
|
IF calculated_depth > 5 THEN
|
||||||
|
RAISE EXCEPTION 'No se permiten subpreguntas con profundidad mayor a 5. Esta pregunta tendría profundidad %, máximo permitido: 5', calculated_depth;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Asignar el nivel calculado
|
||||||
|
NEW.depth_level := calculated_depth;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Crear trigger para INSERT y UPDATE
|
||||||
|
DROP TRIGGER IF EXISTS validate_depth_trigger ON questions;
|
||||||
|
CREATE TRIGGER validate_depth_trigger
|
||||||
|
BEFORE INSERT OR UPDATE OF parent_question_id ON questions
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION validate_question_depth();
|
||||||
|
|
||||||
|
-- Actualizar depth_level para preguntas existentes
|
||||||
|
UPDATE questions
|
||||||
|
SET depth_level = calculate_question_depth(id);
|
||||||
|
|
||||||
|
-- Crear índice para mejorar queries de subpreguntas
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_parent ON questions(parent_question_id) WHERE parent_question_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_depth ON questions(depth_level);
|
||||||
|
|
||||||
|
-- Función helper para obtener árbol de subpreguntas
|
||||||
|
CREATE OR REPLACE FUNCTION get_question_tree(root_question_id INTEGER)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id INTEGER,
|
||||||
|
parent_question_id INTEGER,
|
||||||
|
text TEXT,
|
||||||
|
type VARCHAR(30),
|
||||||
|
depth_level INTEGER,
|
||||||
|
show_if_answer VARCHAR(50),
|
||||||
|
path TEXT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
WITH RECURSIVE question_tree AS (
|
||||||
|
-- Pregunta raíz
|
||||||
|
SELECT
|
||||||
|
q.id,
|
||||||
|
q.parent_question_id,
|
||||||
|
q.text,
|
||||||
|
q.type,
|
||||||
|
q.depth_level,
|
||||||
|
q.show_if_answer,
|
||||||
|
q.id::TEXT as path
|
||||||
|
FROM questions q
|
||||||
|
WHERE q.id = root_question_id
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Subpreguntas recursivas
|
||||||
|
SELECT
|
||||||
|
q.id,
|
||||||
|
q.parent_question_id,
|
||||||
|
q.text,
|
||||||
|
q.type,
|
||||||
|
q.depth_level,
|
||||||
|
q.show_if_answer,
|
||||||
|
qt.path || ' > ' || q.id::TEXT
|
||||||
|
FROM questions q
|
||||||
|
INNER JOIN question_tree qt ON q.parent_question_id = qt.id
|
||||||
|
WHERE q.depth_level <= 5 -- Límite de seguridad
|
||||||
|
)
|
||||||
|
SELECT * FROM question_tree
|
||||||
|
ORDER BY depth_level, id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Verificar estructura actual
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_preguntas,
|
||||||
|
COUNT(CASE WHEN parent_question_id IS NULL THEN 1 END) as principales,
|
||||||
|
COUNT(CASE WHEN parent_question_id IS NOT NULL THEN 1 END) as subpreguntas,
|
||||||
|
MAX(depth_level) as max_profundidad
|
||||||
|
FROM questions;
|
||||||
|
|
||||||
|
-- Ver distribución por profundidad
|
||||||
|
SELECT
|
||||||
|
depth_level,
|
||||||
|
COUNT(*) as cantidad,
|
||||||
|
CASE
|
||||||
|
WHEN depth_level = 0 THEN 'Principales'
|
||||||
|
WHEN depth_level = 1 THEN 'Nivel 1'
|
||||||
|
WHEN depth_level = 2 THEN 'Nivel 2'
|
||||||
|
WHEN depth_level = 3 THEN 'Nivel 3'
|
||||||
|
WHEN depth_level = 4 THEN 'Nivel 4'
|
||||||
|
WHEN depth_level = 5 THEN 'Nivel 5'
|
||||||
|
END as descripcion
|
||||||
|
FROM questions
|
||||||
|
GROUP BY depth_level
|
||||||
|
ORDER BY depth_level;
|
||||||
|
|
||||||
|
SELECT '✓ Migración de subpreguntas anidadas completada' AS status;
|
||||||
25
migrations/add_soft_delete_to_questions.sql
Normal file
25
migrations/add_soft_delete_to_questions.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Migración: Agregar soft delete a preguntas
|
||||||
|
-- Fecha: 2025-11-27
|
||||||
|
-- Descripción: Permite eliminar preguntas sin romper la integridad de respuestas históricas
|
||||||
|
|
||||||
|
-- Agregar columna is_deleted a la tabla questions
|
||||||
|
ALTER TABLE questions ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Agregar columna updated_at si no existe
|
||||||
|
ALTER TABLE questions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();
|
||||||
|
|
||||||
|
-- Crear índice para mejorar queries que filtran por is_deleted
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_is_deleted ON questions(is_deleted);
|
||||||
|
|
||||||
|
-- Crear índice compuesto para mejorar queries de preguntas activas por checklist
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_checklist_active ON questions(checklist_id, is_deleted);
|
||||||
|
|
||||||
|
-- Actualizar preguntas existentes como no eliminadas
|
||||||
|
UPDATE questions SET is_deleted = FALSE WHERE is_deleted IS NULL;
|
||||||
|
|
||||||
|
-- Actualizar updated_at en preguntas existentes
|
||||||
|
UPDATE questions SET updated_at = created_at WHERE updated_at IS NULL;
|
||||||
|
|
||||||
|
-- Comentarios en las columnas
|
||||||
|
COMMENT ON COLUMN questions.is_deleted IS 'Soft delete: marca pregunta como eliminada sin borrarla físicamente, manteniendo integridad de respuestas históricas';
|
||||||
|
COMMENT ON COLUMN questions.updated_at IS 'Timestamp de última actualización de la pregunta';
|
||||||
22
migrations/change_allow_photos_to_photo_requirement.sql
Normal file
22
migrations/change_allow_photos_to_photo_requirement.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- Migración: Cambiar allow_photos de Boolean a String con 3 estados
|
||||||
|
-- Fecha: 2025-12-02
|
||||||
|
-- Descripción: Agregar soporte para fotos opcionales/obligatorias/no permitidas
|
||||||
|
|
||||||
|
-- Paso 1: Agregar nueva columna
|
||||||
|
ALTER TABLE questions ADD COLUMN photo_requirement VARCHAR(20) DEFAULT 'optional';
|
||||||
|
|
||||||
|
-- Paso 2: Migrar datos existentes
|
||||||
|
UPDATE questions
|
||||||
|
SET photo_requirement = CASE
|
||||||
|
WHEN allow_photos = TRUE THEN 'optional'
|
||||||
|
WHEN allow_photos = FALSE THEN 'none'
|
||||||
|
ELSE 'optional'
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Paso 3: Eliminar columna antigua (opcional, comentar si quieres mantener compatibilidad)
|
||||||
|
-- ALTER TABLE questions DROP COLUMN allow_photos;
|
||||||
|
|
||||||
|
-- Nota: Los valores válidos son:
|
||||||
|
-- 'none' = No se permiten fotos
|
||||||
|
-- 'optional' = Fotos opcionales (puede adjuntar o no)
|
||||||
|
-- 'required' = Fotos obligatorias (debe adjuntar al menos 1)
|
||||||
468
migrations/migrate_question_types.py
Normal file
468
migrations/migrate_question_types.py
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script de Migración de Tipos de Preguntas
|
||||||
|
==========================================
|
||||||
|
Migra preguntas existentes (pass_fail, good_bad) al nuevo formato configurable.
|
||||||
|
|
||||||
|
Requisitos:
|
||||||
|
pip install psycopg2-binary
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python migrate_question_types.py
|
||||||
|
|
||||||
|
Base de Datos:
|
||||||
|
Host: portianerp.rshtech.com.py
|
||||||
|
Database: syntria_db
|
||||||
|
User: syntria_user
|
||||||
|
"""
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
|
# Configuración de la base de datos
|
||||||
|
DB_CONFIG = {
|
||||||
|
'host': 'portianerp.rshtech.com.py',
|
||||||
|
'database': 'syntria_db',
|
||||||
|
'user': 'syntria_user',
|
||||||
|
'password': 'syntria_secure_2024',
|
||||||
|
'port': 5432
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plantillas de conversión
|
||||||
|
MIGRATION_TEMPLATES = {
|
||||||
|
'pass_fail': {
|
||||||
|
'new_type': 'boolean',
|
||||||
|
'config': {
|
||||||
|
'type': 'boolean',
|
||||||
|
'choices': [
|
||||||
|
{
|
||||||
|
'value': 'pass',
|
||||||
|
'label': 'Pasa',
|
||||||
|
'points': None, # Se asignará dinámicamente
|
||||||
|
'status': 'ok'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'value': 'fail',
|
||||||
|
'label': 'Falla',
|
||||||
|
'points': 0,
|
||||||
|
'status': 'critical'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'good_bad': {
|
||||||
|
'new_type': 'boolean',
|
||||||
|
'config': {
|
||||||
|
'type': 'boolean',
|
||||||
|
'choices': [
|
||||||
|
{
|
||||||
|
'value': 'good',
|
||||||
|
'label': 'Bueno',
|
||||||
|
'points': None, # Se asignará dinámicamente
|
||||||
|
'status': 'ok'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'value': 'bad',
|
||||||
|
'label': 'Malo',
|
||||||
|
'points': 0,
|
||||||
|
'status': 'critical'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'good_bad_regular': {
|
||||||
|
'new_type': 'single_choice',
|
||||||
|
'config': {
|
||||||
|
'type': 'single_choice',
|
||||||
|
'choices': [
|
||||||
|
{
|
||||||
|
'value': 'good',
|
||||||
|
'label': 'Bueno',
|
||||||
|
'points': None, # Se asignará dinámicamente
|
||||||
|
'status': 'ok'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'value': 'regular',
|
||||||
|
'label': 'Regular',
|
||||||
|
'points': None, # Se calculará como points/2
|
||||||
|
'status': 'warning'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'value': 'bad',
|
||||||
|
'label': 'Malo',
|
||||||
|
'points': 0,
|
||||||
|
'status': 'critical'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'allow_other': False
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'text': {
|
||||||
|
'new_type': 'text',
|
||||||
|
'config': {
|
||||||
|
'type': 'text',
|
||||||
|
'multiline': True,
|
||||||
|
'max_length': 500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'number': {
|
||||||
|
'new_type': 'number',
|
||||||
|
'config': {
|
||||||
|
'type': 'number',
|
||||||
|
'min': 0,
|
||||||
|
'max': 100,
|
||||||
|
'unit': ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionMigrator:
|
||||||
|
def __init__(self):
|
||||||
|
self.conn = None
|
||||||
|
self.cursor = None
|
||||||
|
self.stats = {
|
||||||
|
'total_questions': 0,
|
||||||
|
'migrated': 0,
|
||||||
|
'skipped': 0,
|
||||||
|
'errors': 0,
|
||||||
|
'by_type': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""Conectar a la base de datos"""
|
||||||
|
try:
|
||||||
|
print(f"🔌 Conectando a {DB_CONFIG['host']}...")
|
||||||
|
self.conn = psycopg2.connect(**DB_CONFIG)
|
||||||
|
self.cursor = self.conn.cursor()
|
||||||
|
print("✅ Conexión exitosa\n")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error de conexión: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""Cerrar conexión"""
|
||||||
|
if self.cursor:
|
||||||
|
self.cursor.close()
|
||||||
|
if self.conn:
|
||||||
|
self.conn.close()
|
||||||
|
print("\n🔌 Conexión cerrada")
|
||||||
|
|
||||||
|
def backup_questions_table(self):
|
||||||
|
"""Crear backup de la tabla questions"""
|
||||||
|
try:
|
||||||
|
print("💾 Creando backup de la tabla questions...")
|
||||||
|
|
||||||
|
# Crear tabla de backup con timestamp
|
||||||
|
backup_table = f"questions_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||||
|
|
||||||
|
self.cursor.execute(f"""
|
||||||
|
CREATE TABLE {backup_table} AS
|
||||||
|
SELECT * FROM questions;
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.cursor.execute(f"SELECT COUNT(*) FROM {backup_table}")
|
||||||
|
count = self.cursor.fetchone()[0]
|
||||||
|
|
||||||
|
self.conn.commit()
|
||||||
|
print(f"✅ Backup creado: {backup_table} ({count} registros)\n")
|
||||||
|
return backup_table
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error creando backup: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_questions_to_migrate(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Obtener todas las preguntas que necesitan migración"""
|
||||||
|
try:
|
||||||
|
print("📊 Obteniendo preguntas para migrar...")
|
||||||
|
|
||||||
|
self.cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
checklist_id,
|
||||||
|
section,
|
||||||
|
text,
|
||||||
|
type,
|
||||||
|
points,
|
||||||
|
options,
|
||||||
|
allow_photos,
|
||||||
|
max_photos,
|
||||||
|
requires_comment_on_fail,
|
||||||
|
send_notification,
|
||||||
|
parent_question_id,
|
||||||
|
show_if_answer,
|
||||||
|
ai_prompt
|
||||||
|
FROM questions
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
|
||||||
|
questions = []
|
||||||
|
for row in self.cursor.fetchall():
|
||||||
|
questions.append({
|
||||||
|
'id': row[0],
|
||||||
|
'checklist_id': row[1],
|
||||||
|
'section': row[2],
|
||||||
|
'text': row[3],
|
||||||
|
'type': row[4],
|
||||||
|
'points': row[5],
|
||||||
|
'options': row[6],
|
||||||
|
'allow_photos': row[7],
|
||||||
|
'max_photos': row[8],
|
||||||
|
'requires_comment_on_fail': row[9],
|
||||||
|
'send_notification': row[10],
|
||||||
|
'parent_question_id': row[11],
|
||||||
|
'show_if_answer': row[12],
|
||||||
|
'ai_prompt': row[13]
|
||||||
|
})
|
||||||
|
|
||||||
|
self.stats['total_questions'] = len(questions)
|
||||||
|
print(f"✅ Se encontraron {len(questions)} preguntas\n")
|
||||||
|
|
||||||
|
return questions
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error obteniendo preguntas: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def migrate_question(self, question: Dict[str, Any]) -> bool:
|
||||||
|
"""Migrar una pregunta al nuevo formato"""
|
||||||
|
try:
|
||||||
|
old_type = question['type']
|
||||||
|
|
||||||
|
# Si ya está en el nuevo formato, saltar
|
||||||
|
if old_type in ['boolean', 'single_choice', 'multiple_choice', 'scale', 'text', 'number', 'date', 'time']:
|
||||||
|
if question['options'] and isinstance(question['options'], dict) and 'type' in question['options']:
|
||||||
|
self.stats['skipped'] += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Obtener template de migración
|
||||||
|
if old_type not in MIGRATION_TEMPLATES:
|
||||||
|
print(f" ⚠️ Tipo desconocido '{old_type}' para pregunta #{question['id']}")
|
||||||
|
self.stats['skipped'] += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
template = MIGRATION_TEMPLATES[old_type]
|
||||||
|
new_type = template['new_type']
|
||||||
|
new_config = json.loads(json.dumps(template['config'])) # Deep copy
|
||||||
|
|
||||||
|
# Asignar puntos dinámicamente
|
||||||
|
if 'choices' in new_config:
|
||||||
|
for choice in new_config['choices']:
|
||||||
|
if choice['points'] is None:
|
||||||
|
choice['points'] = question['points']
|
||||||
|
|
||||||
|
# Para good_bad_regular, calcular puntos intermedios
|
||||||
|
if old_type == 'good_bad_regular':
|
||||||
|
new_config['choices'][1]['points'] = max(1, question['points'] // 2)
|
||||||
|
|
||||||
|
# Actualizar la pregunta
|
||||||
|
self.cursor.execute("""
|
||||||
|
UPDATE questions
|
||||||
|
SET
|
||||||
|
type = %s,
|
||||||
|
options = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (new_type, json.dumps(new_config), question['id']))
|
||||||
|
|
||||||
|
# Actualizar estadísticas
|
||||||
|
self.stats['migrated'] += 1
|
||||||
|
if old_type not in self.stats['by_type']:
|
||||||
|
self.stats['by_type'][old_type] = 0
|
||||||
|
self.stats['by_type'][old_type] += 1
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error migrando pregunta #{question['id']}: {e}")
|
||||||
|
self.stats['errors'] += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
def verify_migration(self):
|
||||||
|
"""Verificar que la migración fue exitosa"""
|
||||||
|
print("\n🔍 Verificando migración...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Contar por tipo nuevo
|
||||||
|
self.cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
type,
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(CASE WHEN options IS NOT NULL THEN 1 END) as with_config
|
||||||
|
FROM questions
|
||||||
|
GROUP BY type
|
||||||
|
ORDER BY type
|
||||||
|
""")
|
||||||
|
|
||||||
|
print("\n📊 Distribución de preguntas migradas:")
|
||||||
|
print("-" * 60)
|
||||||
|
for row in self.cursor.fetchall():
|
||||||
|
tipo, total, with_config = row
|
||||||
|
print(f" {tipo:20} | Total: {total:4} | Con config: {with_config:4}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
# Verificar que todas las boolean tengan 2 choices
|
||||||
|
# Usar CAST para compatibilidad con JSON y JSONB
|
||||||
|
self.cursor.execute("""
|
||||||
|
SELECT id, text, options
|
||||||
|
FROM questions
|
||||||
|
WHERE type = 'boolean'
|
||||||
|
AND (
|
||||||
|
options IS NULL
|
||||||
|
OR json_array_length((options::json)->'choices') != 2
|
||||||
|
)
|
||||||
|
LIMIT 5
|
||||||
|
""")
|
||||||
|
|
||||||
|
invalid = self.cursor.fetchall()
|
||||||
|
if invalid:
|
||||||
|
print(f"\n⚠️ Advertencia: {len(invalid)} preguntas boolean con configuración inválida:")
|
||||||
|
for q_id, text, opts in invalid:
|
||||||
|
print(f" - #{q_id}: {text[:50]}...")
|
||||||
|
else:
|
||||||
|
print("\n✅ Todas las preguntas boolean tienen configuración válida")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error en verificación: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def print_statistics(self):
|
||||||
|
"""Imprimir estadísticas de la migración"""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("📈 ESTADÍSTICAS DE MIGRACIÓN")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Total de preguntas: {self.stats['total_questions']}")
|
||||||
|
print(f"Migradas exitosamente: {self.stats['migrated']}")
|
||||||
|
print(f"Omitidas: {self.stats['skipped']}")
|
||||||
|
print(f"Errores: {self.stats['errors']}")
|
||||||
|
print("\nPor tipo original:")
|
||||||
|
for tipo, count in self.stats['by_type'].items():
|
||||||
|
print(f" - {tipo:20}: {count:4} preguntas")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
def run(self, dry_run=False):
|
||||||
|
"""Ejecutar la migración completa"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("🚀 MIGRACIÓN DE TIPOS DE PREGUNTAS")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Modo: {'🔍 DRY RUN (sin cambios)' if dry_run else '✍️ MIGRACIÓN REAL'}")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
if not self.connect():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Crear backup
|
||||||
|
if not dry_run:
|
||||||
|
backup_table = self.backup_questions_table()
|
||||||
|
if not backup_table:
|
||||||
|
print("⚠️ No se pudo crear backup. ¿Continuar de todos modos? (y/n): ", end='')
|
||||||
|
if input().lower() != 'y':
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Obtener preguntas
|
||||||
|
questions = self.get_questions_to_migrate()
|
||||||
|
if not questions:
|
||||||
|
print("❌ No se encontraron preguntas para migrar")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Migrar cada pregunta
|
||||||
|
print("🔄 Migrando preguntas...\n")
|
||||||
|
for i, question in enumerate(questions, 1):
|
||||||
|
old_type = question['type']
|
||||||
|
|
||||||
|
if self.migrate_question(question):
|
||||||
|
if i % 10 == 0:
|
||||||
|
print(f" Progreso: {i}/{len(questions)} preguntas procesadas...")
|
||||||
|
|
||||||
|
# Commit o rollback según modo
|
||||||
|
if dry_run:
|
||||||
|
self.conn.rollback()
|
||||||
|
print("\n🔍 DRY RUN completado - Cambios revertidos")
|
||||||
|
else:
|
||||||
|
self.conn.commit()
|
||||||
|
print("\n✅ Migración completada - Cambios guardados")
|
||||||
|
|
||||||
|
# Verificar migración
|
||||||
|
self.verify_migration()
|
||||||
|
|
||||||
|
# Mostrar estadísticas
|
||||||
|
self.print_statistics()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error durante la migración: {e}")
|
||||||
|
if self.conn:
|
||||||
|
self.conn.rollback()
|
||||||
|
print("🔄 Cambios revertidos")
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Función principal"""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" MIGRACIÓN DE TIPOS DE PREGUNTAS - Sistema Configurable")
|
||||||
|
print(" Base de datos: syntria_db @ portianerp.rshtech.com.py")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
print("Este script migrará las preguntas existentes al nuevo formato:")
|
||||||
|
print(" • pass_fail → boolean (Pasa/Falla)")
|
||||||
|
print(" • good_bad → boolean (Bueno/Malo)")
|
||||||
|
print(" • good_bad_regular → single_choice (Bueno/Regular/Malo)")
|
||||||
|
print(" • text → text (con configuración)")
|
||||||
|
print(" • number → number (con configuración)\n")
|
||||||
|
|
||||||
|
# Preguntar modo
|
||||||
|
print("Seleccione el modo de ejecución:")
|
||||||
|
print(" 1. DRY RUN - Ver cambios sin aplicarlos (recomendado primero)")
|
||||||
|
print(" 2. MIGRACIÓN REAL - Aplicar cambios permanentes")
|
||||||
|
print("\nOpción (1/2): ", end='')
|
||||||
|
|
||||||
|
try:
|
||||||
|
option = input().strip()
|
||||||
|
dry_run = (option != '2')
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
print("\n⚠️ ADVERTENCIA: Esto modificará la base de datos de producción.")
|
||||||
|
print("Se creará un backup automático antes de continuar.")
|
||||||
|
print("\n¿Continuar? (escriba 'SI' para confirmar): ", end='')
|
||||||
|
confirm = input().strip()
|
||||||
|
|
||||||
|
if confirm != 'SI':
|
||||||
|
print("\n❌ Migración cancelada por el usuario")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ejecutar migración
|
||||||
|
migrator = QuestionMigrator()
|
||||||
|
success = migrator.run(dry_run=dry_run)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
if not dry_run:
|
||||||
|
print("\n✅ Migración completada exitosamente!")
|
||||||
|
print("\nPróximos pasos:")
|
||||||
|
print(" 1. Reiniciar el servidor backend")
|
||||||
|
print(" 2. Probar crear nuevas preguntas con el editor visual")
|
||||||
|
print(" 3. Verificar que las inspecciones existentes sigan funcionando")
|
||||||
|
else:
|
||||||
|
print("\n✅ DRY RUN completado!")
|
||||||
|
print("\nPara aplicar los cambios, ejecute nuevamente y seleccione opción 2")
|
||||||
|
else:
|
||||||
|
print("\n❌ La migración falló. Revise los errores arriba.")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⚠️ Migración cancelada por el usuario")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error inesperado: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user