Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7d64e0094 |
@@ -1,7 +1,7 @@
|
||||
# Database
|
||||
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
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
|
||||
133
.github/copilot-instructions.md
vendored
133
.github/copilot-instructions.md
vendored
@@ -1,133 +0,0 @@
|
||||
# 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,8 +29,6 @@ dist-ssr/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
48
README.md
48
README.md
@@ -360,54 +360,6 @@ UPDATE checklists SET max_score = (
|
||||
|
||||
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
|
||||
|
||||
Para problemas o preguntas:
|
||||
|
||||
2342
backend/app/main.py
2342
backend/app/main.py
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,6 @@ class User(Base):
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), nullable=False) # admin, mechanic, asesor
|
||||
full_name = Column(String(100))
|
||||
employee_code = Column(String(50)) # Nro Operario - código de otro sistema
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -66,29 +65,23 @@ class Question(Base):
|
||||
checklist_id = Column(Integer, ForeignKey("checklists.id"), nullable=False)
|
||||
section = Column(String(100)) # Sistema eléctrico, Frenos, etc
|
||||
text = Column(Text, nullable=False)
|
||||
type = Column(String(30), nullable=False) # boolean, single_choice, multiple_choice, scale, text, number, date, time
|
||||
type = Column(String(30), nullable=False) # pass_fail, good_bad, text, etc
|
||||
points = Column(Integer, default=1)
|
||||
options = Column(JSON) # Configuración flexible según tipo de pregunta
|
||||
options = Column(JSON) # Para multiple choice
|
||||
order = Column(Integer, default=0)
|
||||
allow_photos = Column(Boolean, default=True) # DEPRECATED: usar photo_requirement
|
||||
photo_requirement = Column(String(20), default='optional') # none, optional, required
|
||||
allow_photos = Column(Boolean, default=True)
|
||||
max_photos = Column(Integer, default=3)
|
||||
requires_comment_on_fail = Column(Boolean, default=False)
|
||||
send_notification = Column(Boolean, default=False)
|
||||
|
||||
# Conditional logic - Subpreguntas anidadas hasta 5 niveles
|
||||
# Conditional logic
|
||||
parent_question_id = Column(Integer, ForeignKey("questions.id"), nullable=True)
|
||||
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())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
checklist = relationship("Checklist", back_populates="questions")
|
||||
@@ -113,10 +106,7 @@ class Inspection(Base):
|
||||
vehicle_brand = Column(String(50))
|
||||
vehicle_model = Column(String(100))
|
||||
vehicle_km = Column(Integer)
|
||||
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
|
||||
client_name = Column(String(200))
|
||||
|
||||
# Scoring
|
||||
score = Column(Integer, default=0)
|
||||
@@ -125,7 +115,7 @@ class Inspection(Base):
|
||||
flagged_items_count = Column(Integer, default=0)
|
||||
|
||||
# Estado
|
||||
status = Column(String(20), default="incomplete") # incomplete, completed, inactive
|
||||
status = Column(String(20), default="draft") # draft, completed, inactive
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Firma
|
||||
@@ -158,7 +148,6 @@ class Answer(Base):
|
||||
comment = Column(Text) # Comentarios adicionales
|
||||
|
||||
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
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -214,27 +203,6 @@ class ChecklistPermission(Base):
|
||||
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"
|
||||
|
||||
@@ -7,7 +7,6 @@ class UserBase(BaseModel):
|
||||
username: str
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = None
|
||||
employee_code: Optional[str] = None # Nro Operario - código de otro sistema
|
||||
role: str = "mechanic"
|
||||
|
||||
class UserCreate(UserBase):
|
||||
@@ -17,7 +16,6 @@ class UserUpdate(BaseModel):
|
||||
username: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = None
|
||||
employee_code: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
|
||||
class UserPasswordUpdate(BaseModel):
|
||||
@@ -33,7 +31,6 @@ class UserLogin(BaseModel):
|
||||
|
||||
class User(UserBase):
|
||||
id: int
|
||||
employee_code: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
@@ -97,37 +94,20 @@ class Checklist(ChecklistBase):
|
||||
|
||||
|
||||
# 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):
|
||||
section: Optional[str] = None
|
||||
text: str
|
||||
type: str # boolean, single_choice, multiple_choice, scale, text, number, date, time
|
||||
type: str
|
||||
points: int = 1
|
||||
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}
|
||||
options: Optional[dict] = None
|
||||
order: int = 0
|
||||
allow_photos: bool = True # DEPRECATED: mantener por compatibilidad
|
||||
photo_requirement: Optional[str] = 'optional' # none, optional, required
|
||||
allow_photos: bool = True
|
||||
max_photos: int = 3
|
||||
requires_comment_on_fail: bool = False
|
||||
send_notification: bool = False
|
||||
parent_question_id: Optional[int] = None
|
||||
show_if_answer: Optional[str] = None
|
||||
ai_prompt: Optional[str] = None
|
||||
is_deleted: bool = False
|
||||
|
||||
class QuestionCreate(QuestionBase):
|
||||
checklist_id: int
|
||||
@@ -135,37 +115,15 @@ class QuestionCreate(QuestionBase):
|
||||
class QuestionUpdate(QuestionBase):
|
||||
pass
|
||||
|
||||
class QuestionReorder(BaseModel):
|
||||
question_id: int
|
||||
new_order: int
|
||||
|
||||
class Question(QuestionBase):
|
||||
id: int
|
||||
checklist_id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
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
|
||||
class InspectionBase(BaseModel):
|
||||
@@ -175,8 +133,7 @@ class InspectionBase(BaseModel):
|
||||
vehicle_brand: Optional[str] = None
|
||||
vehicle_model: Optional[str] = None
|
||||
vehicle_km: Optional[int] = None
|
||||
order_number: Optional[str] = None # Nº de Pedido
|
||||
mechanic_employee_code: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
|
||||
class InspectionCreate(InspectionBase):
|
||||
checklist_id: int
|
||||
@@ -192,7 +149,6 @@ class Inspection(InspectionBase):
|
||||
id: int
|
||||
checklist_id: int
|
||||
mechanic_id: int
|
||||
mechanic_employee_code: Optional[str] = None
|
||||
score: int
|
||||
max_score: int
|
||||
percentage: float
|
||||
@@ -207,7 +163,7 @@ class Inspection(InspectionBase):
|
||||
|
||||
# Answer Schemas
|
||||
class AnswerBase(BaseModel):
|
||||
answer_value: Optional[str] = None # Opcional para permitir guardar solo análisis IA
|
||||
answer_value: str
|
||||
status: str = "ok"
|
||||
comment: Optional[str] = None
|
||||
is_flagged: bool = False
|
||||
@@ -215,8 +171,6 @@ class AnswerBase(BaseModel):
|
||||
class AnswerCreate(AnswerBase):
|
||||
inspection_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):
|
||||
pass
|
||||
@@ -226,8 +180,7 @@ class Answer(AnswerBase):
|
||||
inspection_id: int
|
||||
question_id: int
|
||||
points_earned: int
|
||||
ai_analysis: Optional[list] = None # Lista de análisis de IA
|
||||
chat_history: Optional[list] = None # Historial de chat con AI Assistant
|
||||
ai_analysis: Optional[dict] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -269,10 +222,9 @@ class InspectionDetail(Inspection):
|
||||
|
||||
# AI Configuration Schemas
|
||||
class AIConfigurationBase(BaseModel):
|
||||
provider: str # openai, gemini, anthropic
|
||||
provider: str # openai, gemini
|
||||
api_key: str
|
||||
model_name: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
|
||||
class AIConfigurationCreate(AIConfigurationBase):
|
||||
pass
|
||||
@@ -281,7 +233,6 @@ class AIConfigurationUpdate(BaseModel):
|
||||
provider: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class AIConfiguration(AIConfigurationBase):
|
||||
|
||||
@@ -22,4 +22,4 @@ if ($LASTEXITCODE -ne 0) {
|
||||
}
|
||||
|
||||
Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green
|
||||
|
||||
pause
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
-- 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';
|
||||
@@ -1,10 +0,0 @@
|
||||
-- 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,12 +10,10 @@ python-jose[cryptography]==3.3.0
|
||||
passlib==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.6
|
||||
openai==1.57.4
|
||||
anthropic==0.40.0
|
||||
openai==1.10.0
|
||||
google-generativeai==0.3.2
|
||||
Pillow==10.2.0
|
||||
reportlab==4.0.9
|
||||
pypdf==4.0.1
|
||||
python-dotenv==1.0.0
|
||||
boto3==1.34.89
|
||||
requests==2.31.0
|
||||
@@ -1,155 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,177 +0,0 @@
|
||||
# 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
|
||||
@@ -1,196 +0,0 @@
|
||||
# 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
|
||||
@@ -1,168 +0,0 @@
|
||||
# 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
|
||||
@@ -24,4 +24,4 @@ if ($LASTEXITCODE -ne 0) {
|
||||
}
|
||||
|
||||
Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green
|
||||
|
||||
pause
|
||||
|
||||
@@ -2,16 +2,8 @@
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<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" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AYUTEC - Sistema Inteligente de Inspecciones</title>
|
||||
<meta name="description" content="AYUTEC: Sistema avanzado de inspecciones vehiculares con inteligencia artificial" />
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "checklist-frontend",
|
||||
"private": true,
|
||||
"version": "1.3.5",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,8 +15,7 @@
|
||||
"axios": "^1.6.5",
|
||||
"react-signature-canvas": "^1.0.6",
|
||||
"lucide-react": "^0.303.0",
|
||||
"clsx": "^2.1.0",
|
||||
"react-markdown": "^9.0.1"
|
||||
"clsx": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
@@ -27,4 +26,4 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 940 B |
@@ -1,67 +0,0 @@
|
||||
// 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.5';
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
3601
frontend/src/App.jsx
3601
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
@@ -1,332 +0,0 @@
|
||||
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
|
||||
@@ -1,658 +0,0 @@
|
||||
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,35 +1,19 @@
|
||||
export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, setSidebarOpen, onLogout }) {
|
||||
return (
|
||||
<>
|
||||
{/* 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
|
||||
`}>
|
||||
<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`}>
|
||||
{/* Sidebar Header */}
|
||||
<div className={`p-4 flex items-center ${sidebarOpen ? 'justify-between' : 'justify-center'} border-b border-indigo-800/50`}>
|
||||
{sidebarOpen && (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/ayutec_logo.png"
|
||||
alt="Ayutec"
|
||||
className="w-8 h-8 object-contain bg-white rounded-lg p-1"
|
||||
/>
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">S</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Ayutec</h2>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 rounded-lg hover:bg-indigo-800/50 transition lg:block"
|
||||
className="p-2 rounded-lg hover:bg-indigo-800/50 transition"
|
||||
title={sidebarOpen ? 'Ocultar sidebar' : 'Mostrar sidebar'}
|
||||
>
|
||||
{sidebarOpen ? '☰' : '☰'}
|
||||
@@ -138,27 +122,6 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
|
||||
|
||||
{/* User Info */}
|
||||
<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.5
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
@@ -187,6 +150,5 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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
|
||||
@@ -1,32 +1,13 @@
|
||||
Clear-Host
|
||||
|
||||
# Crear archivo temporal para el mensaje
|
||||
$tempFile = [System.IO.Path]::GetTempFileName()
|
||||
# Pedir mensaje de commit
|
||||
$mensaje = Read-Host "Ingrese el mensaje de commit"
|
||||
|
||||
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..."
|
||||
Write-Host "Agregando archivos..."
|
||||
git add .
|
||||
|
||||
Write-Host "Creando commit..."
|
||||
git commit -F $tempFile
|
||||
|
||||
# Eliminar archivo temporal después del commit
|
||||
Remove-Item $tempFile -ErrorAction SilentlyContinue
|
||||
git commit -m "$mensaje"
|
||||
|
||||
Write-Host "Haciendo push a la rama develop..."
|
||||
$pushOutput = git push origin develop 2>&1
|
||||
@@ -42,4 +23,4 @@ if ($pushOutput -match "Authentication failed" -or $pushOutput -match "Failed to
|
||||
}
|
||||
|
||||
Write-Host "`nProceso finalizado."
|
||||
|
||||
pause
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- 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';
|
||||
@@ -1,16 +0,0 @@
|
||||
-- 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';
|
||||
@@ -1,153 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,23 +0,0 @@
|
||||
-- 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';
|
||||
@@ -1,172 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- 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';
|
||||
@@ -1,22 +0,0 @@
|
||||
-- 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)
|
||||
@@ -1,468 +0,0 @@
|
||||
#!/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