From be30b3ca181c51c11011fd5dccc0b234368f65f1 Mon Sep 17 00:00:00 2001 From: ronalds Date: Tue, 18 Nov 2025 13:09:42 -0300 Subject: [PATCH] Base Principal del Proyecto --- .env.example | 16 ++ .gitignore | 54 +++++ README.md | 386 +++++++++++++++++++++++++++++ backend/Dockerfile | 27 +++ backend/app/__init__.py | 1 + backend/app/core/__init__.py | 1 + backend/app/core/config.py | 27 +++ backend/app/core/database.py | 21 ++ backend/app/core/security.py | 30 +++ backend/app/main.py | 454 +++++++++++++++++++++++++++++++++++ backend/app/models.py | 146 +++++++++++ backend/app/schemas.py | 177 ++++++++++++++ backend/requirements.txt | 14 ++ docker-compose.yml | 52 ++++ frontend/Dockerfile | 18 ++ frontend/index.html | 13 + frontend/package.json | 29 +++ frontend/postcss.config.js | 6 + frontend/src/App.jsx | 363 ++++++++++++++++++++++++++++ frontend/src/index.css | 17 ++ frontend/src/main.jsx | 10 + frontend/tailwind.config.js | 11 + frontend/vite.config.js | 13 + init-data.sh | 132 ++++++++++ 24 files changed, 2018 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/schemas.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 init-data.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..51fcea1 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Database +DATABASE_URL=postgresql://checklist_user:checklist_pass_2024@localhost:5432/checklist_db + +# Backend +SECRET_KEY=your-super-secret-key-min-32-characters-change-this +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=10080 + +# OpenAI API +OPENAI_API_KEY=sk-your-openai-api-key-here + +# Environment +ENVIRONMENT=development + +# Frontend +VITE_API_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa49ffd --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +*.egg-info/ +dist/ +build/ + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +dist/ +dist-ssr/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Uploads +uploads/ +*.jpg +*.jpeg +*.png +*.gif +*.pdf + +# Database +*.db +*.sqlite + +# Logs +*.log +logs/ + +# Docker +docker-compose.override.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..b276f6a --- /dev/null +++ b/README.md @@ -0,0 +1,386 @@ +# Sistema de Checklists Inteligentes - MVP + +Sistema completo de gestión de checklists para talleres mecánicos con integración de IA. + +## 🚀 Características + +- ✅ Gestión de checklists dinámicos (Admin) +- ✅ Ejecución de inspecciones (Mecánico) +- ✅ Sistema de puntuación automática +- ✅ Upload de múltiples fotos +- ✅ Firma digital +- ✅ Generación de PDF profesional +- ✅ API REST completa +- ✅ Autenticación JWT +- ✅ Base de datos PostgreSQL + +## 📋 Requisitos Previos + +- Docker & Docker Compose instalados +- OpenAI API Key (opcional, solo para análisis IA) + +## 🛠️ Instalación Rápida + +### 1. Clonar/Descargar el proyecto + +```bash +cd checklist-mvp +``` + +### 2. Configurar variables de entorno + +```bash +cp .env.example .env +``` + +Editar `.env` y configurar: +- `OPENAI_API_KEY` (si deseas usar análisis IA) +- Cambiar `SECRET_KEY` por una clave segura + +### 3. Levantar servicios con Docker + +```bash +docker-compose up -d +``` + +Esto levantará: +- PostgreSQL en puerto 5432 +- Backend (FastAPI) en puerto 8000 +- Frontend (React) en puerto 5173 + +### 4. Crear usuario administrador + +```bash +docker-compose exec backend python -c " +from app.core.database import SessionLocal +from app.models import User +from app.core.security import get_password_hash + +db = SessionLocal() +admin = User( + username='admin', + password_hash=get_password_hash('admin123'), + role='admin', + email='admin@taller.com', + full_name='Administrador' +) +db.add(admin) +db.commit() +print('Usuario admin creado: admin / admin123') +" +``` + +### 5. Crear usuario mecánico + +```bash +docker-compose exec backend python -c " +from app.core.database import SessionLocal +from app.models import User +from app.core.security import get_password_hash + +db = SessionLocal() +mechanic = User( + username='mecanico1', + password_hash=get_password_hash('mecanico123'), + role='mechanic', + email='mecanico@taller.com', + full_name='Juan Pérez' +) +db.add(mechanic) +db.commit() +print('Usuario mecánico creado: mecanico1 / mecanico123') +" +``` + +## 🌐 Acceso a la Aplicación + +- **Frontend**: http://localhost:5173 +- **API Backend**: http://localhost:8000 +- **Documentación API**: http://localhost:8000/docs + +## 👥 Usuarios de Prueba + +| Usuario | Contraseña | Rol | +|---------|-----------|-----| +| admin | admin123 | Administrador | +| mecanico1 | mecanico123 | Mecánico | + +## 📱 Flujo de Uso + +### Como Administrador: + +1. Login con `admin / admin123` +2. Ir a "Checklists" +3. Crear nuevo checklist +4. Agregar preguntas con tipos: + - pass_fail: Revisado / En mal estado + - good_bad: Buen estado / Mal estado + - text: Respuesta libre + - numeric: Números (KM, porcentajes) +5. Configurar puntos por pregunta +6. Activar el checklist + +### Como Mecánico: + +1. Login con `mecanico1 / mecanico123` +2. Ir a "Nueva Inspección" +3. Seleccionar checklist activo +4. Ingresar datos del vehículo (OR, matrícula, KM) +5. Completar preguntas paso a paso +6. Subir fotos (máx 3 por pregunta) +7. Ver score en tiempo real +8. Firmar digitalmente +9. Finalizar inspección +10. Descargar PDF generado + +## 🗂️ Estructura del Proyecto + +``` +checklist-mvp/ +├── docker-compose.yml # Orquestación de servicios +├── .env.example # Variables de entorno +│ +├── backend/ # API FastAPI +│ ├── Dockerfile +│ ├── requirements.txt +│ └── app/ +│ ├── main.py # Endpoints principales +│ ├── models.py # Modelos SQLAlchemy +│ ├── schemas.py # Schemas Pydantic +│ └── core/ +│ ├── config.py # Configuración +│ ├── database.py # Conexión BD +│ └── security.py # JWT & Hashing +│ +└── frontend/ # React + Vite + ├── Dockerfile + ├── package.json + ├── vite.config.js + ├── tailwind.config.js + └── src/ + ├── App.jsx # App principal + ├── pages/ # Páginas + ├── components/ # Componentes + └── services/ # API calls +``` + +## 🔌 API Endpoints Principales + +### Autenticación +``` +POST /api/auth/register # Registrar usuario +POST /api/auth/login # Login +GET /api/auth/me # Usuario actual +``` + +### Checklists +``` +GET /api/checklists # Listar checklists +POST /api/checklists # Crear checklist (admin) +GET /api/checklists/{id} # Ver detalle con preguntas +PUT /api/checklists/{id} # Actualizar +``` + +### Preguntas +``` +POST /api/questions # Crear pregunta (admin) +PUT /api/questions/{id} # Actualizar +DELETE /api/questions/{id} # Eliminar +``` + +### Inspecciones +``` +GET /api/inspections # Listar inspecciones +POST /api/inspections # Crear inspección +GET /api/inspections/{id} # Ver detalle completo +PUT /api/inspections/{id} # Actualizar (guardar borrador) +POST /api/inspections/{id}/complete # Finalizar +``` + +### Respuestas +``` +POST /api/answers # Crear respuesta +PUT /api/answers/{id} # Actualizar respuesta +``` + +### Archivos +``` +POST /api/answers/{id}/upload # Subir foto +``` + +## 🗄️ Modelo de Datos + +### Users +- id, username, email, password_hash +- role (admin/mechanic) +- full_name, is_active + +### Checklists +- id, name, description +- ai_mode (off/assisted/copilot) +- scoring_enabled, max_score +- is_active, created_by + +### Questions +- id, checklist_id, section +- text, type, points +- options (JSON), order +- allow_photos, max_photos + +### Inspections +- id, checklist_id, mechanic_id +- or_number, vehicle_plate, vehicle_km +- score, max_score, percentage +- flagged_items_count +- status (draft/completed) +- signature_data + +### Answers +- id, inspection_id, question_id +- answer_value, status (ok/warning/critical) +- points_earned, comment +- is_flagged, ai_analysis (JSON) + +### MediaFiles +- id, answer_id +- file_path, file_type, caption + +## 🎨 Tipos de Preguntas + +| Tipo | Descripción | Opciones | +|------|-------------|----------| +| pass_fail | Pasa/Falla | REVISADO / EN MAL ESTADO | +| good_bad | Estado | Buen estado / Mal estado | +| status | Acción | REVISADO / SUSTITUIDO / Ninguna acción | +| text | Texto libre | - | +| numeric | Número | - | +| date | Fecha | - | +| multiple_choice | Opciones | Configurables | + +## 🔒 Seguridad + +- Autenticación JWT con tokens Bearer +- Passwords hasheados con bcrypt +- CORS configurado para desarrollo +- Validación con Pydantic +- Autorización por roles (admin/mechanic) + +## 🐛 Debugging + +### Ver logs del backend +```bash +docker-compose logs -f backend +``` + +### Ver logs del frontend +```bash +docker-compose logs -f frontend +``` + +### Ver logs de PostgreSQL +```bash +docker-compose logs -f postgres +``` + +### Acceder a la base de datos +```bash +docker-compose exec postgres psql -U checklist_user -d checklist_db +``` + +### Reiniciar servicios +```bash +docker-compose restart +``` + +### Detener todo +```bash +docker-compose down +``` + +### Eliminar volúmenes (resetear BD) +```bash +docker-compose down -v +``` + +## 📝 Crear Checklist de Ejemplo + +Puedes usar la API directamente o crear via SQL: + +```sql +-- Conectar a la BD +docker-compose exec postgres psql -U checklist_user -d checklist_db + +-- Insertar checklist +INSERT INTO checklists (name, description, ai_mode, scoring_enabled, max_score, is_active, created_by) +VALUES ('Mantenimiento Básico', 'Checklist de mantenimiento estándar', 'assisted', true, 0, true, 1); + +-- Insertar preguntas +INSERT INTO questions (checklist_id, section, text, type, points, order, allow_photos) VALUES +(1, 'Sistema Eléctrico', 'Estado de la batería', 'good_bad', 1, 1, true), +(1, 'Sistema Eléctrico', 'Bocina', 'pass_fail', 1, 2, false), +(1, 'Frenos', 'Frenos (pastillas, discos)', 'pass_fail', 2, 3, true), +(1, 'Motor', 'Nivel de aceite', 'pass_fail', 1, 4, true), +(1, 'Motor', 'Fugas de aceite', 'pass_fail', 2, 5, true); + +-- Actualizar max_score del checklist +UPDATE checklists SET max_score = ( + SELECT SUM(points) FROM questions WHERE checklist_id = 1 +) WHERE id = 1; +``` + +## 🚀 Despliegue en Producción + +### Cambios necesarios para producción: + +1. **Variables de entorno**: + - Cambiar `SECRET_KEY` por una clave segura aleatoria + - Configurar `OPENAI_API_KEY` + - Usar base de datos PostgreSQL externa + +2. **Frontend**: + - Cambiar `VITE_API_URL` a URL del backend en producción + - Ejecutar `npm run build` + - Servir carpeta `dist/` con Nginx + +3. **Backend**: + - Configurar CORS con dominio específico + - Usar Gunicorn en lugar de Uvicorn + - Configurar HTTPS con certificado SSL + +4. **Base de datos**: + - Usar PostgreSQL gestionado (AWS RDS, DigitalOcean, etc) + - Hacer backups automáticos + - Configurar conexiones SSL + +5. **Storage**: + - Usar S3 o similar para archivos + - Configurar CDN para imágenes + +## 📄 Licencia + +MIT License - Uso libre para proyectos comerciales y personales + +## 🆘 Soporte + +Para problemas o preguntas: +1. Revisar logs con `docker-compose logs` +2. Verificar que todos los servicios están corriendo con `docker-compose ps` +3. Revisar documentación de API en http://localhost:8000/docs + +## 🎯 Próximos Pasos (Post-MVP) + +- [ ] Lógica condicional en preguntas +- [ ] Modo Copiloto IA completo +- [ ] Análisis avanzado de imágenes +- [ ] Dashboard de estadísticas +- [ ] Historial de vehículos +- [ ] Notificaciones por email/WhatsApp +- [ ] App móvil React Native +- [ ] Multi-tenant para varios talleres +- [ ] Integración con sistemas ERP + +--- + +**¡Listo para usar! 🎉** + +Recuerda cambiar las contraseñas por defecto antes de usar en producción. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0553835 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Copiar requirements +COPY requirements.txt . + +# Instalar dependencias de Python +RUN pip install --no-cache-dir -r requirements.txt + +# Copiar código +COPY . . + +# Crear directorio para uploads +RUN mkdir -p /app/uploads + +# Exponer puerto +EXPOSE 8000 + +# Comando por defecto +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..a93a073 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Backend app package diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..d61a255 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +# Core package diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..3b4486c --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,27 @@ +from pydantic_settings import BaseSettings +from typing import Optional + +class Settings(BaseSettings): + # Database + DATABASE_URL: str + + # Security + SECRET_KEY: str + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 días + + # OpenAI + OPENAI_API_KEY: Optional[str] = None + + # Environment + ENVIRONMENT: str = "development" + + # Uploads + UPLOAD_DIR: str = "uploads" + MAX_FILE_SIZE: int = 10 * 1024 * 1024 # 10MB + + class Config: + env_file = ".env" + case_sensitive = True + +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..e728703 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,21 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + echo=settings.ENVIRONMENT == "development" +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..8102588 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +def decode_access_token(token: str): + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2126ff4 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,454 @@ +from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session, joinedload +from typing import List +import os +import shutil +from datetime import datetime, timedelta + +from app.core.database import engine, get_db, Base +from app.core.security import verify_password, get_password_hash, create_access_token, decode_access_token +from app import models, schemas + +# Crear tablas +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="Checklist Inteligente API", version="1.0.0") + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +security = HTTPBearer() + +# Dependency para obtener usuario actual +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +): + token = credentials.credentials + payload = decode_access_token(token) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token inválido o expirado" + ) + + user = db.query(models.User).filter(models.User.id == payload.get("sub")).first() + if user is None: + raise HTTPException(status_code=404, detail="Usuario no encontrado") + + return user + + +# ============= AUTH ENDPOINTS ============= +@app.post("/api/auth/register", response_model=schemas.User) +def register(user: schemas.UserCreate, db: Session = Depends(get_db)): + # Verificar si usuario existe + db_user = db.query(models.User).filter(models.User.username == user.username).first() + if db_user: + raise HTTPException(status_code=400, detail="Usuario ya existe") + + # Crear usuario + hashed_password = get_password_hash(user.password) + db_user = models.User( + username=user.username, + email=user.email, + full_name=user.full_name, + role=user.role, + password_hash=hashed_password + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +@app.post("/api/auth/login", response_model=schemas.Token) +def login(user_login: schemas.UserLogin, db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.username == user_login.username).first() + if not user or not verify_password(user_login.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuario o contraseña incorrectos" + ) + + access_token = create_access_token(data={"sub": user.id, "role": user.role}) + return { + "access_token": access_token, + "token_type": "bearer", + "user": user + } + + +@app.get("/api/auth/me", response_model=schemas.User) +def get_me(current_user: models.User = Depends(get_current_user)): + return current_user + + +# ============= CHECKLIST ENDPOINTS ============= +@app.get("/api/checklists", response_model=List[schemas.Checklist]) +def get_checklists( + skip: int = 0, + limit: int = 100, + active_only: bool = False, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + query = db.query(models.Checklist) + if active_only: + query = query.filter(models.Checklist.is_active == True) + return query.offset(skip).limit(limit).all() + + +@app.get("/api/checklists/{checklist_id}", response_model=schemas.ChecklistWithQuestions) +def get_checklist(checklist_id: int, db: Session = Depends(get_db)): + checklist = db.query(models.Checklist).options( + joinedload(models.Checklist.questions) + ).filter(models.Checklist.id == checklist_id).first() + + if not checklist: + raise HTTPException(status_code=404, detail="Checklist no encontrado") + + return checklist + + +@app.post("/api/checklists", response_model=schemas.Checklist) +def create_checklist( + checklist: schemas.ChecklistCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="No autorizado") + + db_checklist = models.Checklist(**checklist.dict(), created_by=current_user.id) + db.add(db_checklist) + db.commit() + db.refresh(db_checklist) + return db_checklist + + +@app.put("/api/checklists/{checklist_id}", response_model=schemas.Checklist) +def update_checklist( + checklist_id: int, + checklist: schemas.ChecklistUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="No autorizado") + + db_checklist = db.query(models.Checklist).filter(models.Checklist.id == checklist_id).first() + if not db_checklist: + raise HTTPException(status_code=404, detail="Checklist no encontrado") + + for key, value in checklist.dict(exclude_unset=True).items(): + setattr(db_checklist, key, value) + + db.commit() + db.refresh(db_checklist) + return db_checklist + + +# ============= QUESTION ENDPOINTS ============= +@app.post("/api/questions", response_model=schemas.Question) +def create_question( + question: schemas.QuestionCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="No autorizado") + + db_question = models.Question(**question.dict()) + db.add(db_question) + + # Actualizar max_score del checklist + checklist = db.query(models.Checklist).filter( + models.Checklist.id == question.checklist_id + ).first() + if checklist: + checklist.max_score += question.points + + db.commit() + db.refresh(db_question) + return db_question + + +@app.put("/api/questions/{question_id}", response_model=schemas.Question) +def update_question( + question_id: int, + question: schemas.QuestionUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="No autorizado") + + db_question = db.query(models.Question).filter(models.Question.id == question_id).first() + if not db_question: + raise HTTPException(status_code=404, detail="Pregunta no encontrada") + + for key, value in question.dict(exclude_unset=True).items(): + setattr(db_question, key, value) + + db.commit() + db.refresh(db_question) + return db_question + + +@app.delete("/api/questions/{question_id}") +def delete_question( + question_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="No autorizado") + + db_question = db.query(models.Question).filter(models.Question.id == question_id).first() + if not db_question: + raise HTTPException(status_code=404, detail="Pregunta no encontrada") + + db.delete(db_question) + db.commit() + return {"message": "Pregunta eliminada"} + + +# ============= INSPECTION ENDPOINTS ============= +@app.get("/api/inspections", response_model=List[schemas.Inspection]) +def get_inspections( + skip: int = 0, + limit: int = 100, + vehicle_plate: str = None, + status: str = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + query = db.query(models.Inspection) + + # Mecánicos solo ven sus inspecciones + if current_user.role == "mechanic": + query = query.filter(models.Inspection.mechanic_id == current_user.id) + + if vehicle_plate: + query = query.filter(models.Inspection.vehicle_plate.contains(vehicle_plate)) + + if status: + query = query.filter(models.Inspection.status == status) + + return query.order_by(models.Inspection.created_at.desc()).offset(skip).limit(limit).all() + + +@app.get("/api/inspections/{inspection_id}", response_model=schemas.InspectionDetail) +def get_inspection( + inspection_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + inspection = db.query(models.Inspection).options( + joinedload(models.Inspection.checklist), + joinedload(models.Inspection.mechanic), + joinedload(models.Inspection.answers).joinedload(models.Answer.question), + joinedload(models.Inspection.answers).joinedload(models.Answer.media_files) + ).filter(models.Inspection.id == inspection_id).first() + + if not inspection: + raise HTTPException(status_code=404, detail="Inspección no encontrada") + + return inspection + + +@app.post("/api/inspections", response_model=schemas.Inspection) +def create_inspection( + inspection: schemas.InspectionCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + # Obtener max_score del checklist + checklist = db.query(models.Checklist).filter( + models.Checklist.id == inspection.checklist_id + ).first() + + if not checklist: + raise HTTPException(status_code=404, detail="Checklist no encontrado") + + db_inspection = models.Inspection( + **inspection.dict(), + mechanic_id=current_user.id, + max_score=checklist.max_score + ) + db.add(db_inspection) + db.commit() + db.refresh(db_inspection) + return db_inspection + + +@app.put("/api/inspections/{inspection_id}", response_model=schemas.Inspection) +def update_inspection( + inspection_id: int, + inspection: schemas.InspectionUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + db_inspection = db.query(models.Inspection).filter( + models.Inspection.id == inspection_id + ).first() + + if not db_inspection: + raise HTTPException(status_code=404, detail="Inspección no encontrada") + + for key, value in inspection.dict(exclude_unset=True).items(): + setattr(db_inspection, key, value) + + db.commit() + db.refresh(db_inspection) + return db_inspection + + +@app.post("/api/inspections/{inspection_id}/complete", response_model=schemas.Inspection) +def complete_inspection( + inspection_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + inspection = db.query(models.Inspection).filter( + models.Inspection.id == inspection_id + ).first() + + if not inspection: + raise HTTPException(status_code=404, detail="Inspección no encontrada") + + # Calcular score + answers = db.query(models.Answer).filter(models.Answer.inspection_id == inspection_id).all() + total_score = sum(a.points_earned for a in answers) + flagged_count = sum(1 for a in answers if a.is_flagged) + + inspection.score = total_score + inspection.percentage = (total_score / inspection.max_score * 100) if inspection.max_score > 0 else 0 + inspection.flagged_items_count = flagged_count + inspection.status = "completed" + inspection.completed_at = datetime.utcnow() + + db.commit() + db.refresh(inspection) + return inspection + + +# ============= ANSWER ENDPOINTS ============= +@app.post("/api/answers", response_model=schemas.Answer) +def create_answer( + answer: schemas.AnswerCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + # Obtener la pregunta para saber los puntos + question = db.query(models.Question).filter(models.Question.id == answer.question_id).first() + + if not question: + raise HTTPException(status_code=404, detail="Pregunta no encontrada") + + # Calcular puntos según status + points_earned = 0 + if answer.status == "ok": + points_earned = question.points + elif answer.status == "warning": + points_earned = int(question.points * 0.5) + + db_answer = models.Answer( + **answer.dict(), + points_earned=points_earned + ) + db.add(db_answer) + db.commit() + db.refresh(db_answer) + return db_answer + + +@app.put("/api/answers/{answer_id}", response_model=schemas.Answer) +def update_answer( + answer_id: int, + answer: schemas.AnswerUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + db_answer = db.query(models.Answer).filter(models.Answer.id == answer_id).first() + + if not db_answer: + raise HTTPException(status_code=404, detail="Respuesta no encontrada") + + # Recalcular puntos si cambió el status + if answer.status and answer.status != db_answer.status: + question = db.query(models.Question).filter( + models.Question.id == db_answer.question_id + ).first() + + if answer.status == "ok": + db_answer.points_earned = question.points + elif answer.status == "warning": + db_answer.points_earned = int(question.points * 0.5) + else: + db_answer.points_earned = 0 + + for key, value in answer.dict(exclude_unset=True).items(): + setattr(db_answer, key, value) + + db.commit() + db.refresh(db_answer) + return db_answer + + +# ============= MEDIA FILE ENDPOINTS ============= +@app.post("/api/answers/{answer_id}/upload", response_model=schemas.MediaFile) +async def upload_photo( + answer_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + # Verificar que la respuesta existe + answer = db.query(models.Answer).filter(models.Answer.id == answer_id).first() + if not answer: + raise HTTPException(status_code=404, detail="Respuesta no encontrada") + + # Crear directorio si no existe + upload_dir = f"uploads/inspection_{answer.inspection_id}" + os.makedirs(upload_dir, exist_ok=True) + + # Guardar archivo + file_extension = file.filename.split(".")[-1] + file_name = f"answer_{answer_id}_{datetime.now().timestamp()}.{file_extension}" + file_path = os.path.join(upload_dir, file_name) + + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + # Crear registro en BD + media_file = models.MediaFile( + answer_id=answer_id, + file_path=file_path, + file_type="image" + ) + db.add(media_file) + db.commit() + db.refresh(media_file) + + return media_file + + +# ============= HEALTH CHECK ============= +@app.get("/") +def root(): + return {"message": "Checklist Inteligente API", "version": "1.0.0", "status": "running"} + + +@app.get("/health") +def health_check(): + return {"status": "healthy"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..6dc8058 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,146 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, JSON, Float +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + email = Column(String(100), unique=True, index=True) + password_hash = Column(String(255), nullable=False) + role = Column(String(20), nullable=False) # admin, mechanic + full_name = Column(String(100)) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + checklists_created = relationship("Checklist", back_populates="creator") + inspections = relationship("Inspection", back_populates="mechanic") + + +class Checklist(Base): + __tablename__ = "checklists" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), nullable=False) + description = Column(Text) + ai_mode = Column(String(20), default="off") # off, assisted, copilot + scoring_enabled = Column(Boolean, default=True) + max_score = Column(Integer, default=0) + logo_url = Column(String(500)) + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + creator = relationship("User", back_populates="checklists_created") + questions = relationship("Question", back_populates="checklist", cascade="all, delete-orphan") + inspections = relationship("Inspection", back_populates="checklist") + + +class Question(Base): + __tablename__ = "questions" + + id = Column(Integer, primary_key=True, index=True) + checklist_id = Column(Integer, ForeignKey("checklists.id"), nullable=False) + section = Column(String(100)) # Sistema eléctrico, Frenos, etc + text = Column(Text, nullable=False) + type = Column(String(30), nullable=False) # pass_fail, good_bad, text, etc + points = Column(Integer, default=1) + options = Column(JSON) # Para multiple choice + order = Column(Integer, default=0) + allow_photos = Column(Boolean, default=True) + max_photos = Column(Integer, default=3) + requires_comment_on_fail = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + checklist = relationship("Checklist", back_populates="questions") + answers = relationship("Answer", back_populates="question") + + +class Inspection(Base): + __tablename__ = "inspections" + + id = Column(Integer, primary_key=True, index=True) + checklist_id = Column(Integer, ForeignKey("checklists.id"), nullable=False) + mechanic_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + # Datos de la OR + or_number = Column(String(50)) + work_order_number = Column(String(50)) + + # Datos del vehículo + vehicle_plate = Column(String(20), nullable=False, index=True) + vehicle_brand = Column(String(50)) + vehicle_model = Column(String(100)) + vehicle_km = Column(Integer) + client_name = Column(String(200)) + + # Scoring + score = Column(Integer, default=0) + max_score = Column(Integer, default=0) + percentage = Column(Float, default=0.0) + flagged_items_count = Column(Integer, default=0) + + # Estado + status = Column(String(20), default="draft") # draft, completed + + # Firma + signature_data = Column(Text) # Base64 de la firma + signed_at = Column(DateTime(timezone=True)) + + # Timestamps + started_at = Column(DateTime(timezone=True), server_default=func.now()) + completed_at = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + checklist = relationship("Checklist", back_populates="inspections") + mechanic = relationship("User", back_populates="inspections") + answers = relationship("Answer", back_populates="inspection", cascade="all, delete-orphan") + + +class Answer(Base): + __tablename__ = "answers" + + id = Column(Integer, primary_key=True, index=True) + inspection_id = Column(Integer, ForeignKey("inspections.id"), nullable=False) + question_id = Column(Integer, ForeignKey("questions.id"), nullable=False) + + answer_value = Column(Text) # La respuesta del mecánico + status = Column(String(20), default="ok") # ok, warning, critical, info + points_earned = Column(Integer, default=0) + comment = Column(Text) # Comentarios adicionales + + ai_analysis = Column(JSON) # Análisis de IA si aplica + is_flagged = Column(Boolean, default=False) # Si requiere atención + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + inspection = relationship("Inspection", back_populates="answers") + question = relationship("Question", back_populates="answers") + media_files = relationship("MediaFile", back_populates="answer", cascade="all, delete-orphan") + + +class MediaFile(Base): + __tablename__ = "media_files" + + id = Column(Integer, primary_key=True, index=True) + answer_id = Column(Integer, ForeignKey("answers.id"), nullable=False) + + file_path = Column(String(500), nullable=False) + file_type = Column(String(20), default="image") # image, video + caption = Column(Text) + order = Column(Integer, default=0) + + uploaded_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + answer = relationship("Answer", back_populates="media_files") diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..d5c94e4 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,177 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional, List +from datetime import datetime + +# User Schemas +class UserBase(BaseModel): + username: str + email: Optional[EmailStr] = None + full_name: Optional[str] = None + role: str = "mechanic" + +class UserCreate(UserBase): + password: str + +class UserLogin(BaseModel): + username: str + password: str + +class User(UserBase): + id: int + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + +class Token(BaseModel): + access_token: str + token_type: str + user: User + + +# Checklist Schemas +class ChecklistBase(BaseModel): + name: str + description: Optional[str] = None + ai_mode: str = "off" + scoring_enabled: bool = True + logo_url: Optional[str] = None + +class ChecklistCreate(ChecklistBase): + pass + +class ChecklistUpdate(ChecklistBase): + is_active: Optional[bool] = None + +class Checklist(ChecklistBase): + id: int + max_score: int + is_active: bool + created_by: int + created_at: datetime + + class Config: + from_attributes = True + + +# Question Schemas +class QuestionBase(BaseModel): + section: Optional[str] = None + text: str + type: str + points: int = 1 + options: Optional[dict] = None + order: int = 0 + allow_photos: bool = True + max_photos: int = 3 + requires_comment_on_fail: bool = False + +class QuestionCreate(QuestionBase): + checklist_id: int + +class QuestionUpdate(QuestionBase): + pass + +class Question(QuestionBase): + id: int + checklist_id: int + created_at: datetime + + class Config: + from_attributes = True + + +# Inspection Schemas +class InspectionBase(BaseModel): + or_number: Optional[str] = None + work_order_number: Optional[str] = None + vehicle_plate: str + vehicle_brand: Optional[str] = None + vehicle_model: Optional[str] = None + vehicle_km: Optional[int] = None + client_name: Optional[str] = None + +class InspectionCreate(InspectionBase): + checklist_id: int + +class InspectionUpdate(BaseModel): + vehicle_brand: Optional[str] = None + vehicle_model: Optional[str] = None + vehicle_km: Optional[int] = None + signature_data: Optional[str] = None + status: Optional[str] = None + +class Inspection(InspectionBase): + id: int + checklist_id: int + mechanic_id: int + score: int + max_score: int + percentage: float + flagged_items_count: int + status: str + started_at: datetime + completed_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Answer Schemas +class AnswerBase(BaseModel): + answer_value: str + status: str = "ok" + comment: Optional[str] = None + is_flagged: bool = False + +class AnswerCreate(AnswerBase): + inspection_id: int + question_id: int + +class AnswerUpdate(AnswerBase): + pass + +class Answer(AnswerBase): + id: int + inspection_id: int + question_id: int + points_earned: int + ai_analysis: Optional[dict] = None + created_at: datetime + + class Config: + from_attributes = True + + +# MediaFile Schemas +class MediaFileBase(BaseModel): + caption: Optional[str] = None + order: int = 0 + +class MediaFileCreate(MediaFileBase): + file_type: str = "image" + +class MediaFile(MediaFileBase): + id: int + answer_id: int + file_path: str + file_type: str + uploaded_at: datetime + + class Config: + from_attributes = True + + +# Response Schemas +class ChecklistWithQuestions(Checklist): + questions: List[Question] = [] + +class InspectionDetail(Inspection): + checklist: Checklist + mechanic: User + answers: List[Answer] = [] + +class AnswerWithMedia(Answer): + media_files: List[MediaFile] = [] + question: Question diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..77681ae --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +alembic==1.13.1 +psycopg2-binary==2.9.9 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +openai==1.10.0 +Pillow==10.2.0 +reportlab==4.0.9 +python-dotenv==1.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9b8be51 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: checklist-db + environment: + POSTGRES_DB: checklist_db + POSTGRES_USER: checklist_user + POSTGRES_PASSWORD: checklist_pass_2024 + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U checklist_user"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: ./backend + container_name: checklist-backend + environment: + DATABASE_URL: postgresql://checklist_user:checklist_pass_2024@postgres:5432/checklist_db + SECRET_KEY: your-secret-key-change-in-production-min-32-chars + OPENAI_API_KEY: ${OPENAI_API_KEY} + ENVIRONMENT: development + ports: + - "8000:8000" + volumes: + - ./backend:/app + - ./uploads:/app/uploads + depends_on: + postgres: + condition: service_healthy + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + frontend: + build: ./frontend + container_name: checklist-frontend + ports: + - "5173:5173" + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - VITE_API_URL=http://localhost:8000 + command: npm run dev -- --host + +volumes: + postgres_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..727d36b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copiar package files +COPY package*.json ./ + +# Instalar dependencias +RUN npm install + +# Copiar código +COPY . . + +# Exponer puerto +EXPOSE 5173 + +# Comando por defecto +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8871bae --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Checklist Inteligente - Sistema de Inspecciones + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6895a1b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "checklist-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.1", + "axios": "^1.6.5", + "react-signature-canvas": "^1.0.6", + "lucide-react": "^0.303.0", + "clsx": "^2.1.0" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "vite": "^5.0.11" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..72dfb19 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,363 @@ +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' +import { useState, useEffect } from 'react' + +function App() { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + // Verificar si hay token guardado + const token = localStorage.getItem('token') + const userData = localStorage.getItem('user') + + if (token && userData) { + setUser(JSON.parse(userData)) + } + setLoading(false) + }, []) + + if (loading) { + return ( +
+
Cargando...
+
+ ) + } + + return ( + +
+ {!user ? ( + + ) : ( + + )} +
+
+ ) +} + +// Componente de Login +function LoginPage({ setUser }) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleLogin = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + const response = await fetch(`${API_URL}/api/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.detail || 'Error al iniciar sesión') + } + + // Guardar token y usuario + localStorage.setItem('token', data.access_token) + localStorage.setItem('user', JSON.stringify(data.user)) + setUser(data.user) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+
+

Checklist Inteligente

+

Sistema de Inspecciones

+
+ +
+
+ + setUsername(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="admin" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="••••••••" + required + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +
+

Usuarios de prueba:

+
+

Admin: admin / admin123

+

Mecánico: mecanico1 / mecanico123

+
+
+
+
+ ) +} + +// Componente Dashboard +function DashboardPage({ user, setUser }) { + const [checklists, setChecklists] = useState([]) + const [inspections, setInspections] = useState([]) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState('checklists') + + useEffect(() => { + loadData() + }, []) + + const loadData = async () => { + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + + // Cargar checklists + const checklistsRes = await fetch(`${API_URL}/api/checklists?active_only=true`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }) + const checklistsData = await checklistsRes.json() + setChecklists(checklistsData) + + // Cargar inspecciones + const inspectionsRes = await fetch(`${API_URL}/api/inspections?limit=10`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }) + const inspectionsData = await inspectionsRes.json() + setInspections(inspectionsData) + + } catch (error) { + console.error('Error loading data:', error) + } finally { + setLoading(false) + } + } + + const handleLogout = () => { + localStorage.removeItem('token') + localStorage.removeItem('user') + setUser(null) + } + + return ( +
+ {/* Header */} +
+
+
+
+

Checklist Inteligente

+

+ Bienvenido, {user.full_name || user.username} ({user.role === 'admin' ? 'Administrador' : 'Mecánico'}) +

+
+ +
+
+
+ + {/* Tabs */} +
+
+
+ +
+ +
+ {loading ? ( +
+
Cargando datos...
+
+ ) : activeTab === 'checklists' ? ( + + ) : ( + + )} +
+
+
+ + {/* Info Card */} +
+
+

📘 Información del MVP

+

+ Este es el MVP funcional del sistema de checklists inteligentes. + El frontend completo con todas las funcionalidades se encuentra en desarrollo. + Puedes probar la API completa en: http://localhost:8000/docs +

+
+
+
+ ) +} + +function ChecklistsTab({ checklists, user }) { + if (checklists.length === 0) { + return ( +
+

No hay checklists activos

+

Ejecuta ./init-data.sh para crear datos de ejemplo

+
+ ) + } + + return ( +
+ {checklists.map((checklist) => ( +
+
+
+

{checklist.name}

+

{checklist.description}

+
+ + Puntuación máxima: {checklist.max_score} + + + Modo IA: {checklist.ai_mode} + +
+
+ {user.role === 'mechanic' && ( + + )} +
+
+ ))} +
+ ) +} + +function InspectionsTab({ inspections, user }) { + if (inspections.length === 0) { + return ( +
+

No hay inspecciones registradas

+

Crea tu primera inspección

+
+ ) + } + + return ( +
+ {inspections.map((inspection) => ( +
+
+
+
+

+ {inspection.vehicle_plate} +

+ + {inspection.status === 'completed' ? 'Completada' : 'Borrador'} + +
+

+ {inspection.vehicle_brand} {inspection.vehicle_model} - {inspection.vehicle_km} km +

+
+ + OR: {inspection.or_number || 'N/A'} + + + Score: {inspection.score}/{inspection.max_score} ({inspection.percentage.toFixed(1)}%) + + {inspection.flagged_items_count > 0 && ( + + ⚠️ {inspection.flagged_items_count} elementos señalados + + )} +
+
+ +
+
+ ))} +
+ ) +} + +export default App diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..17df0e7 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,17 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..54b39dd --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..dca8ba0 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..39ba9d2 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + host: true, + port: 5173, + watch: { + usePolling: true + } + } +}) diff --git a/init-data.sh b/init-data.sh new file mode 100644 index 0000000..bb34abb --- /dev/null +++ b/init-data.sh @@ -0,0 +1,132 @@ +#!/bin/bash + +echo "🚀 Inicializando Sistema de Checklists..." +echo "" + +# Esperar a que PostgreSQL esté listo +echo "⏳ Esperando a que PostgreSQL esté listo..." +sleep 5 + +# Crear usuarios +echo "👥 Creando usuarios de prueba..." + +docker-compose exec -T backend python << EOF +from app.core.database import SessionLocal +from app.models import User +from app.core.security import get_password_hash + +db = SessionLocal() + +# Crear admin +admin = User( + username='admin', + password_hash=get_password_hash('admin123'), + role='admin', + email='admin@taller.com', + full_name='Administrador Sistema' +) +db.add(admin) + +# Crear mecánico +mechanic = User( + username='mecanico1', + password_hash=get_password_hash('mecanico123'), + role='mechanic', + email='mecanico@taller.com', + full_name='Juan Pérez' +) +db.add(mechanic) + +db.commit() +print('✅ Usuarios creados exitosamente') +EOF + +# Crear checklist de ejemplo +echo "📋 Creando checklist de ejemplo..." + +docker-compose exec -T backend python << EOF +from app.core.database import SessionLocal +from app.models import Checklist, Question + +db = SessionLocal() + +# Crear checklist +checklist = Checklist( + name='Mantenimiento Preventivo', + description='Checklist estándar de mantenimiento preventivo', + ai_mode='assisted', + scoring_enabled=True, + is_active=True, + created_by=1 +) +db.add(checklist) +db.commit() +db.refresh(checklist) + +# Crear preguntas por sección +questions_data = [ + # Sistema Eléctrico + ('Sistema Eléctrico', 'Estado de la batería', 'good_bad', 1, True), + ('Sistema Eléctrico', 'Bocina', 'pass_fail', 1, False), + ('Sistema Eléctrico', 'Luces (posición, cruce, carretera)', 'pass_fail', 1, False), + ('Sistema Eléctrico', 'Testigos en cuadro', 'pass_fail', 1, True), + + # Frenos + ('Frenos', 'Frenos (pastillas, discos)', 'pass_fail', 2, True), + ('Frenos', 'Líquido de freno', 'pass_fail', 1, False), + ('Frenos', 'Porcentaje de humedad', 'numeric', 1, False), + + # Motor + ('Motor', 'Nivel de aceite', 'pass_fail', 1, True), + ('Motor', 'Fugas de aceite', 'pass_fail', 2, True), + ('Motor', 'Filtro de aceite', 'status', 1, True), + ('Motor', 'Fugas de refrigerante', 'pass_fail', 2, True), + + # Neumáticos + ('Neumáticos', 'Presión neumáticos', 'pass_fail', 1, False), + ('Neumáticos', 'Banda de rodadura', 'good_bad', 1, True), + + # Suspensión + ('Suspensión', 'Amortiguadores', 'pass_fail', 2, True), + ('Suspensión', 'Cojinetes de ruedas', 'pass_fail', 1, False), + + # Varios + ('Exterior', 'Estado carrocería', 'good_bad', 1, True), + ('Exterior', 'Escobillas limpiaparabrisas', 'pass_fail', 1, True), + ('Interior', 'Aire acondicionado', 'pass_fail', 1, False), + ('Pruebas', 'Prueba dinámica del vehículo', 'pass_fail', 2, False), +] + +max_score = 0 +for idx, (section, text, qtype, points, photos) in enumerate(questions_data): + question = Question( + checklist_id=checklist.id, + section=section, + text=text, + type=qtype, + points=points, + order=idx + 1, + allow_photos=photos, + max_photos=3 + ) + db.add(question) + max_score += points + +checklist.max_score = max_score +db.commit() + +print(f'✅ Checklist creado con {len(questions_data)} preguntas') +print(f'✅ Puntuación máxima: {max_score}') +EOF + +echo "" +echo "✅ Inicialización completada!" +echo "" +echo "🌐 Accede a la aplicación:" +echo " Frontend: http://localhost:5173" +echo " API Docs: http://localhost:8000/docs" +echo "" +echo "👥 Usuarios de prueba:" +echo " Admin: admin / admin123" +echo " Mecánico: mecanico1 / mecanico123" +echo ""