Compare commits

1 Commits

Author SHA1 Message Date
e7d64e0094 Merge pull request 'develop' (#1) from develop into main
Reviewed-on: #1
2025-11-26 01:15:20 +00:00
39 changed files with 760 additions and 8367 deletions

View File

@@ -1,7 +1,7 @@
# Database # Database
DATABASE_URL=postgresql://checklist_user:checklist_pass_2024@localhost:5432/checklist_db DATABASE_URL=postgresql://checklist_user:checklist_pass_2024@localhost:5432/checklist_db
# Backend # Backend
SECRET_KEY=your-super-secret-key-min-32-characters-change-this SECRET_KEY=your-super-secret-key-min-32-characters-change-this
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=10080 ACCESS_TOKEN_EXPIRE_MINUTES=10080

View File

@@ -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
View File

@@ -29,8 +29,6 @@ dist-ssr/
*.swp *.swp
*.swo *.swo
*~ *~
__pycache__/
*.pyc
# OS # OS
.DS_Store .DS_Store

View File

@@ -360,54 +360,6 @@ UPDATE checklists SET max_score = (
MIT License - Uso libre para proyectos comerciales y personales MIT License - Uso libre para proyectos comerciales y personales
## 📝 Control de Versiones
### Instrucciones para commits de Git
**IMPORTANTE**: Siempre incluir la versión actualizada en los mensajes de commit.
Formato recomendado:
```bash
git add .
git commit -m "tipo: descripción del cambio
- Detalle 1
- Detalle 2
- Frontend vX.X.XX / Backend vX.X.XX"
```
Tipos de commit:
- `feat`: Nueva funcionalidad
- `fix`: Corrección de bugs
- `refactor`: Refactorización de código
- `style`: Cambios de formato/estilo
- `docs`: Actualización de documentación
- `perf`: Mejoras de rendimiento
- `test`: Añadir o actualizar tests
**Ejemplo real**:
```bash
git add .
git commit -m "feat: Add pagination (10 items/page) to all main tabs
- Pagination for Inspections, Checklists, and Reports
- Auto-reset on filter changes
- Smart page navigation with ellipsis
- Result counters showing X-Y of Z items
- Frontend v1.0.64"
```
### Versionado
Seguir **Semantic Versioning** (MAJOR.MINOR.PATCH):
- **MAJOR**: Cambios incompatibles en la API
- **MINOR**: Nueva funcionalidad compatible con versiones anteriores
- **PATCH**: Correcciones de bugs
Ubicación de versiones:
- Frontend: `frontend/package.json``"version": "X.X.XX"`
- Backend: `backend/app/main.py``version="X.X.XX"` en FastAPI app
## 🆘 Soporte ## 🆘 Soporte
Para problemas o preguntas: Para problemas o preguntas:

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@ class User(Base):
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
role = Column(String(20), nullable=False) # admin, mechanic, asesor role = Column(String(20), nullable=False) # admin, mechanic, asesor
full_name = Column(String(100)) full_name = Column(String(100))
employee_code = Column(String(50)) # Nro Operario - código de otro sistema
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -66,29 +65,23 @@ class Question(Base):
checklist_id = Column(Integer, ForeignKey("checklists.id"), nullable=False) checklist_id = Column(Integer, ForeignKey("checklists.id"), nullable=False)
section = Column(String(100)) # Sistema eléctrico, Frenos, etc section = Column(String(100)) # Sistema eléctrico, Frenos, etc
text = Column(Text, nullable=False) text = Column(Text, nullable=False)
type = Column(String(30), nullable=False) # 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) 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) order = Column(Integer, default=0)
allow_photos = Column(Boolean, default=True) # DEPRECATED: usar photo_requirement allow_photos = Column(Boolean, default=True)
photo_requirement = Column(String(20), default='optional') # none, optional, required
max_photos = Column(Integer, default=3) max_photos = Column(Integer, default=3)
requires_comment_on_fail = Column(Boolean, default=False) requires_comment_on_fail = Column(Boolean, default=False)
send_notification = Column(Boolean, default=False) 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) parent_question_id = Column(Integer, ForeignKey("questions.id"), nullable=True)
show_if_answer = Column(String(50), nullable=True) # Valor que dispara esta pregunta show_if_answer = Column(String(50), nullable=True) # Valor que dispara esta pregunta
depth_level = Column(Integer, default=0) # 0=principal, 1-5=subpreguntas anidadas
# AI Analysis # AI Analysis
ai_prompt = Column(Text, nullable=True) # Prompt personalizado para análisis de IA de esta pregunta ai_prompt = Column(Text, nullable=True) # Prompt personalizado para análisis de IA de esta pregunta
# Soft Delete
is_deleted = Column(Boolean, default=False) # Soft delete: mantiene integridad de respuestas históricas
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships # Relationships
checklist = relationship("Checklist", back_populates="questions") checklist = relationship("Checklist", back_populates="questions")
@@ -113,10 +106,7 @@ class Inspection(Base):
vehicle_brand = Column(String(50)) vehicle_brand = Column(String(50))
vehicle_model = Column(String(100)) vehicle_model = Column(String(100))
vehicle_km = Column(Integer) vehicle_km = Column(Integer)
order_number = Column(String(200)) # Nº de Pedido client_name = Column(String(200))
# Datos del mecánico
mechanic_employee_code = Column(String(50)) # Código de operario del mecánico
# Scoring # Scoring
score = Column(Integer, default=0) score = Column(Integer, default=0)
@@ -125,7 +115,7 @@ class Inspection(Base):
flagged_items_count = Column(Integer, default=0) flagged_items_count = Column(Integer, default=0)
# Estado # Estado
status = Column(String(20), default="incomplete") # incomplete, completed, inactive status = Column(String(20), default="draft") # draft, completed, inactive
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
# Firma # Firma
@@ -158,7 +148,6 @@ class Answer(Base):
comment = Column(Text) # Comentarios adicionales comment = Column(Text) # Comentarios adicionales
ai_analysis = Column(JSON) # Análisis de IA si aplica ai_analysis = Column(JSON) # Análisis de IA si aplica
chat_history = Column(JSON) # Historial de chat con AI Assistant (para tipo ai_assistant)
is_flagged = Column(Boolean, default=False) # Si requiere atención is_flagged = Column(Boolean, default=False) # Si requiere atención
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -214,27 +203,6 @@ class ChecklistPermission(Base):
mechanic = relationship("User") 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): class InspectionAuditLog(Base):
"""Registro de auditoría para cambios en inspecciones y respuestas""" """Registro de auditoría para cambios en inspecciones y respuestas"""
__tablename__ = "inspection_audit_log" __tablename__ = "inspection_audit_log"

View File

@@ -7,7 +7,6 @@ class UserBase(BaseModel):
username: str username: str
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
full_name: Optional[str] = None full_name: Optional[str] = None
employee_code: Optional[str] = None # Nro Operario - código de otro sistema
role: str = "mechanic" role: str = "mechanic"
class UserCreate(UserBase): class UserCreate(UserBase):
@@ -17,7 +16,6 @@ class UserUpdate(BaseModel):
username: Optional[str] = None username: Optional[str] = None
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
full_name: Optional[str] = None full_name: Optional[str] = None
employee_code: Optional[str] = None
role: Optional[str] = None role: Optional[str] = None
class UserPasswordUpdate(BaseModel): class UserPasswordUpdate(BaseModel):
@@ -33,7 +31,6 @@ class UserLogin(BaseModel):
class User(UserBase): class User(UserBase):
id: int id: int
employee_code: Optional[str] = None
is_active: bool is_active: bool
created_at: datetime created_at: datetime
@@ -97,37 +94,20 @@ class Checklist(ChecklistBase):
# Question Schemas # Question Schemas
# Tipos de preguntas soportados:
# - boolean: Dos opciones personalizables (ej: Sí/No, Pasa/Falla)
# - single_choice: Selección única con N opciones
# - multiple_choice: Selección múltiple
# - scale: Escala numérica (1-5, 1-10, etc.)
# - text: Texto libre
# - number: Valor numérico
# - date: Fecha
# - time: Hora
class QuestionBase(BaseModel): class QuestionBase(BaseModel):
section: Optional[str] = None section: Optional[str] = None
text: str text: str
type: str # boolean, single_choice, multiple_choice, scale, text, number, date, time type: str
points: int = 1 points: int = 1
options: Optional[dict] = None # Configuración flexible según tipo options: Optional[dict] = None
# Estructura de options:
# Boolean: {"type": "boolean", "choices": [{"value": "yes", "label": "Sí", "points": 1, "status": "ok"}, ...]}
# Single/Multiple Choice: {"type": "single_choice", "choices": [{"value": "opt1", "label": "Opción 1", "points": 2}, ...]}
# Scale: {"type": "scale", "min": 1, "max": 5, "step": 1, "labels": {"min": "Muy malo", "max": "Excelente"}}
# Text: {"type": "text", "multiline": true, "max_length": 500}
order: int = 0 order: int = 0
allow_photos: bool = True # DEPRECATED: mantener por compatibilidad allow_photos: bool = True
photo_requirement: Optional[str] = 'optional' # none, optional, required
max_photos: int = 3 max_photos: int = 3
requires_comment_on_fail: bool = False requires_comment_on_fail: bool = False
send_notification: bool = False send_notification: bool = False
parent_question_id: Optional[int] = None parent_question_id: Optional[int] = None
show_if_answer: Optional[str] = None show_if_answer: Optional[str] = None
ai_prompt: Optional[str] = None ai_prompt: Optional[str] = None
is_deleted: bool = False
class QuestionCreate(QuestionBase): class QuestionCreate(QuestionBase):
checklist_id: int checklist_id: int
@@ -135,37 +115,15 @@ class QuestionCreate(QuestionBase):
class QuestionUpdate(QuestionBase): class QuestionUpdate(QuestionBase):
pass pass
class QuestionReorder(BaseModel):
question_id: int
new_order: int
class Question(QuestionBase): class Question(QuestionBase):
id: int id: int
checklist_id: int checklist_id: int
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None
class Config: class Config:
from_attributes = True from_attributes = True
# Question Audit Schemas
class QuestionAuditLog(BaseModel):
id: int
question_id: int
checklist_id: int
user_id: int
action: str
field_name: Optional[str] = None
old_value: Optional[str] = None
new_value: Optional[str] = None
comment: Optional[str] = None
created_at: datetime
user: Optional['User'] = None
class Config:
from_attributes = True
# Inspection Schemas # Inspection Schemas
class InspectionBase(BaseModel): class InspectionBase(BaseModel):
@@ -175,8 +133,7 @@ class InspectionBase(BaseModel):
vehicle_brand: Optional[str] = None vehicle_brand: Optional[str] = None
vehicle_model: Optional[str] = None vehicle_model: Optional[str] = None
vehicle_km: Optional[int] = None vehicle_km: Optional[int] = None
order_number: Optional[str] = None # Nº de Pedido client_name: Optional[str] = None
mechanic_employee_code: Optional[str] = None
class InspectionCreate(InspectionBase): class InspectionCreate(InspectionBase):
checklist_id: int checklist_id: int
@@ -192,7 +149,6 @@ class Inspection(InspectionBase):
id: int id: int
checklist_id: int checklist_id: int
mechanic_id: int mechanic_id: int
mechanic_employee_code: Optional[str] = None
score: int score: int
max_score: int max_score: int
percentage: float percentage: float
@@ -207,7 +163,7 @@ class Inspection(InspectionBase):
# Answer Schemas # Answer Schemas
class AnswerBase(BaseModel): class AnswerBase(BaseModel):
answer_value: Optional[str] = None # Opcional para permitir guardar solo análisis IA answer_value: str
status: str = "ok" status: str = "ok"
comment: Optional[str] = None comment: Optional[str] = None
is_flagged: bool = False is_flagged: bool = False
@@ -215,8 +171,6 @@ class AnswerBase(BaseModel):
class AnswerCreate(AnswerBase): class AnswerCreate(AnswerBase):
inspection_id: int inspection_id: int
question_id: int question_id: int
ai_analysis: Optional[list] = None # Lista de análisis de IA (soporta múltiples imágenes)
chat_history: Optional[list] = None # Historial de chat con AI Assistant
class AnswerUpdate(AnswerBase): class AnswerUpdate(AnswerBase):
pass pass
@@ -226,8 +180,7 @@ class Answer(AnswerBase):
inspection_id: int inspection_id: int
question_id: int question_id: int
points_earned: int points_earned: int
ai_analysis: Optional[list] = None # Lista de análisis de IA ai_analysis: Optional[dict] = None
chat_history: Optional[list] = None # Historial de chat con AI Assistant
created_at: datetime created_at: datetime
class Config: class Config:
@@ -269,10 +222,9 @@ class InspectionDetail(Inspection):
# AI Configuration Schemas # AI Configuration Schemas
class AIConfigurationBase(BaseModel): class AIConfigurationBase(BaseModel):
provider: str # openai, gemini, anthropic provider: str # openai, gemini
api_key: str api_key: str
model_name: Optional[str] = None model_name: Optional[str] = None
logo_url: Optional[str] = None
class AIConfigurationCreate(AIConfigurationBase): class AIConfigurationCreate(AIConfigurationBase):
pass pass
@@ -281,7 +233,6 @@ class AIConfigurationUpdate(BaseModel):
provider: Optional[str] = None provider: Optional[str] = None
api_key: Optional[str] = None api_key: Optional[str] = None
model_name: Optional[str] = None model_name: Optional[str] = None
logo_url: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
class AIConfiguration(AIConfigurationBase): class AIConfiguration(AIConfigurationBase):

View File

@@ -22,4 +22,4 @@ if ($LASTEXITCODE -ne 0) {
} }
Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green
pause

View File

@@ -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';

View File

@@ -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';

View File

@@ -10,12 +10,10 @@ python-jose[cryptography]==3.3.0
passlib==1.7.4 passlib==1.7.4
bcrypt==4.0.1 bcrypt==4.0.1
python-multipart==0.0.6 python-multipart==0.0.6
openai==1.57.4 openai==1.10.0
anthropic==0.40.0
google-generativeai==0.3.2 google-generativeai==0.3.2
Pillow==10.2.0 Pillow==10.2.0
reportlab==4.0.9 reportlab==4.0.9
pypdf==4.0.1
python-dotenv==1.0.0 python-dotenv==1.0.0
boto3==1.34.89 boto3==1.34.89
requests==2.31.0 requests==2.31.0

View File

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -24,4 +24,4 @@ if ($LASTEXITCODE -ne 0) {
} }
Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green
pause

View File

@@ -2,16 +2,8 @@
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" />
<title>AYUTEC - Sistema Inteligente de Inspecciones</title> <title>AYUTEC - Sistema Inteligente de Inspecciones</title>
<meta name="description" content="AYUTEC: Sistema avanzado de inspecciones vehiculares con inteligencia artificial" /> <meta name="description" content="AYUTEC: Sistema avanzado de inspecciones vehiculares con inteligencia artificial" />
</head> </head>

View File

@@ -1,7 +1,7 @@
{ {
"name": "checklist-frontend", "name": "checklist-frontend",
"private": true, "private": true,
"version": "1.3.5", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -15,8 +15,7 @@
"axios": "^1.6.5", "axios": "^1.6.5",
"react-signature-canvas": "^1.0.6", "react-signature-canvas": "^1.0.6",
"lucide-react": "^0.303.0", "lucide-react": "^0.303.0",
"clsx": "^2.1.0", "clsx": "^2.1.0"
"react-markdown": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
@@ -27,4 +26,4 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"vite": "^5.0.11" "vite": "^5.0.11"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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

View File

@@ -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();
}
});

View File

@@ -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"
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -1,35 +1,19 @@
export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, setSidebarOpen, onLogout }) { export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, setSidebarOpen, onLogout }) {
return ( return (
<> <aside className={`bg-gradient-to-b from-gray-900 via-indigo-950 to-purple-950 text-white transition-all duration-300 ${sidebarOpen ? 'w-64' : 'w-16'} flex flex-col fixed h-full z-10 shadow-2xl`}>
{/* Overlay para cerrar sidebar en móvil */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside className={`bg-gradient-to-b from-gray-900 via-indigo-950 to-purple-950 text-white transition-all duration-300 flex flex-col fixed h-full shadow-2xl
${sidebarOpen ? 'w-64' : 'w-16'}
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
z-30 lg:z-10
`}>
{/* Sidebar Header */} {/* Sidebar Header */}
<div className={`p-4 flex items-center ${sidebarOpen ? 'justify-between' : 'justify-center'} border-b border-indigo-800/50`}> <div className={`p-4 flex items-center ${sidebarOpen ? 'justify-between' : 'justify-center'} border-b border-indigo-800/50`}>
{sidebarOpen && ( {sidebarOpen && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <div className="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-lg flex items-center justify-center">
src="/ayutec_logo.png" <span className="text-white font-bold text-lg">S</span>
alt="Ayutec" </div>
className="w-8 h-8 object-contain bg-white rounded-lg p-1"
/>
<h2 className="text-xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Ayutec</h2> <h2 className="text-xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Ayutec</h2>
</div> </div>
)} )}
<button <button
onClick={() => setSidebarOpen(!sidebarOpen)} onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 rounded-lg hover:bg-indigo-800/50 transition lg:block" className="p-2 rounded-lg hover:bg-indigo-800/50 transition"
title={sidebarOpen ? 'Ocultar sidebar' : 'Mostrar sidebar'} title={sidebarOpen ? 'Ocultar sidebar' : 'Mostrar sidebar'}
> >
{sidebarOpen ? '☰' : '☰'} {sidebarOpen ? '☰' : '☰'}
@@ -138,27 +122,6 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
{/* User Info */} {/* User Info */}
<div className="p-4 border-t border-indigo-800/50"> <div className="p-4 border-t border-indigo-800/50">
{/* Versión */}
{sidebarOpen && (
<div className="mb-3 px-2 py-1.5 bg-indigo-900/30 rounded-lg border border-indigo-700/30">
<a
href="https://ayutec.es"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 hover:opacity-80 transition-opacity"
>
<img
src="/ayutec_logo.webp"
alt="Ayutec"
className="w-10 h-10 object-contain bg-white rounded p-1"
/>
<p className="text-xs text-indigo-300 font-medium hover:text-indigo-200">
Ayutec v1.3.5
</p>
</a>
</div>
)}
<div className={`flex items-center gap-3 ${!sidebarOpen && 'justify-center'}`}> <div className={`flex items-center gap-3 ${!sidebarOpen && 'justify-center'}`}>
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full flex items-center justify-center text-white font-bold flex-shrink-0 shadow-lg"> <div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full flex items-center justify-center text-white font-bold flex-shrink-0 shadow-lg">
{user.username.charAt(0).toUpperCase()} {user.username.charAt(0).toUpperCase()}
@@ -187,6 +150,5 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
</button> </button>
</div> </div>
</aside> </aside>
</>
) )
} }

View File

@@ -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

View File

@@ -1,32 +1,13 @@
Clear-Host Clear-Host
# Crear archivo temporal para el mensaje # Pedir mensaje de commit
$tempFile = [System.IO.Path]::GetTempFileName() $mensaje = Read-Host "Ingrese el mensaje de commit"
Write-Host "Abriendo editor de texto..." -ForegroundColor Cyan Write-Host "Agregando archivos..."
Write-Host "1. Pegue su mensaje de commit"
Write-Host "2. Guarde el archivo (Ctrl+S)"
Write-Host "3. Cierre el editor (Alt+F4 o X)" -ForegroundColor Green
Write-Host ""
# Abrir notepad con el archivo temporal
notepad++.exe $tempFile | Out-Null
# Verificar que el archivo tenga contenido
if (-not (Test-Path $tempFile) -or (Get-Item $tempFile).Length -eq 0) {
Write-Host "No se ingreso ningun mensaje. Abortando..." -ForegroundColor Red
Remove-Item $tempFile -ErrorAction SilentlyContinue
exit
}
Write-Host "`nAgregando archivos..."
git add . git add .
Write-Host "Creando commit..." Write-Host "Creando commit..."
git commit -F $tempFile git commit -m "$mensaje"
# Eliminar archivo temporal después del commit
Remove-Item $tempFile -ErrorAction SilentlyContinue
Write-Host "Haciendo push a la rama develop..." Write-Host "Haciendo push a la rama develop..."
$pushOutput = git push origin develop 2>&1 $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." Write-Host "`nProceso finalizado."
pause

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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)

View File

@@ -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()