Base Principal del Proyecto

This commit is contained in:
2025-11-18 13:09:42 -03:00
commit be30b3ca18
24 changed files with 2018 additions and 0 deletions

16
.env.example Normal file
View File

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

54
.gitignore vendored Normal file
View File

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

386
README.md Normal file
View File

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

27
backend/Dockerfile Normal file
View File

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

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Backend app package

View File

@@ -0,0 +1 @@
# Core package

View File

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

View File

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

View File

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

454
backend/app/main.py Normal file
View File

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

146
backend/app/models.py Normal file
View File

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

177
backend/app/schemas.py Normal file
View File

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

14
backend/requirements.txt Normal file
View File

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

52
docker-compose.yml Normal file
View File

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

18
frontend/Dockerfile Normal file
View File

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

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Checklist Inteligente - Sistema de Inspecciones</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

29
frontend/package.json Normal file
View File

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

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

363
frontend/src/App.jsx Normal file
View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-xl">Cargando...</div>
</div>
)
}
return (
<Router>
<div className="min-h-screen bg-gray-50">
{!user ? (
<LoginPage setUser={setUser} />
) : (
<DashboardPage user={user} setUser={setUser} />
)}
</div>
</Router>
)
}
// 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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-500 to-blue-700 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Checklist Inteligente</h1>
<p className="text-gray-600 mt-2">Sistema de Inspecciones</p>
</div>
<form onSubmit={handleLogin} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Usuario
</label>
<input
type="text"
value={username}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contraseña
</label>
<input
type="password"
value={password}
onChange={(e) => 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
/>
</div>
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
</button>
</form>
<div className="mt-8 pt-6 border-t border-gray-200">
<p className="text-sm text-gray-600 text-center">Usuarios de prueba:</p>
<div className="mt-2 space-y-1 text-xs text-gray-500 text-center">
<p><strong>Admin:</strong> admin / admin123</p>
<p><strong>Mecánico:</strong> mecanico1 / mecanico123</p>
</div>
</div>
</div>
</div>
)
}
// 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 (
<div className="min-h-screen bg-gray-100">
{/* Header */}
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Checklist Inteligente</h1>
<p className="text-sm text-gray-600">
Bienvenido, {user.full_name || user.username} ({user.role === 'admin' ? 'Administrador' : 'Mecánico'})
</p>
</div>
<button
onClick={handleLogout}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
>
Cerrar Sesión
</button>
</div>
</div>
</header>
{/* Tabs */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-6">
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
<button
onClick={() => setActiveTab('checklists')}
className={`px-6 py-3 text-sm font-medium border-b-2 ${
activeTab === 'checklists'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Checklists
</button>
<button
onClick={() => setActiveTab('inspections')}
className={`px-6 py-3 text-sm font-medium border-b-2 ${
activeTab === 'inspections'
: 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Inspecciones
</button>
</nav>
</div>
<div className="p-6">
{loading ? (
<div className="text-center py-12">
<div className="text-gray-500">Cargando datos...</div>
</div>
) : activeTab === 'checklists' ? (
<ChecklistsTab checklists={checklists} user={user} />
) : (
<InspectionsTab inspections={inspections} user={user} />
)}
</div>
</div>
</div>
{/* Info Card */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-6 mb-6">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 mb-2">📘 Información del MVP</h3>
<p className="text-sm text-blue-700">
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: <a href="http://localhost:8000/docs" target="_blank" className="underline font-medium">http://localhost:8000/docs</a>
</p>
</div>
</div>
</div>
)
}
function ChecklistsTab({ checklists, user }) {
if (checklists.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No hay checklists activos</p>
<p className="text-sm text-gray-400 mt-2">Ejecuta ./init-data.sh para crear datos de ejemplo</p>
</div>
)
}
return (
<div className="space-y-4">
{checklists.map((checklist) => (
<div key={checklist.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold text-gray-900">{checklist.name}</h3>
<p className="text-sm text-gray-600 mt-1">{checklist.description}</p>
<div className="flex gap-4 mt-3 text-sm">
<span className="text-gray-500">
Puntuación máxima: <strong>{checklist.max_score}</strong>
</span>
<span className="text-gray-500">
Modo IA: <strong className="capitalize">{checklist.ai_mode}</strong>
</span>
</div>
</div>
{user.role === 'mechanic' && (
<button className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
Nueva Inspección
</button>
)}
</div>
</div>
))}
</div>
)
}
function InspectionsTab({ inspections, user }) {
if (inspections.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500">No hay inspecciones registradas</p>
<p className="text-sm text-gray-400 mt-2">Crea tu primera inspección</p>
</div>
)
}
return (
<div className="space-y-4">
{inspections.map((inspection) => (
<div key={inspection.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-gray-900">
{inspection.vehicle_plate}
</h3>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
inspection.status === 'completed'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{inspection.status === 'completed' ? 'Completada' : 'Borrador'}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">
{inspection.vehicle_brand} {inspection.vehicle_model} - {inspection.vehicle_km} km
</p>
<div className="flex gap-4 mt-3 text-sm">
<span className="text-gray-500">
OR: <strong>{inspection.or_number || 'N/A'}</strong>
</span>
<span className="text-gray-500">
Score: <strong>{inspection.score}/{inspection.max_score}</strong> ({inspection.percentage.toFixed(1)}%)
</span>
{inspection.flagged_items_count > 0 && (
<span className="text-red-600">
<strong>{inspection.flagged_items_count}</strong> elementos señalados
</span>
)}
</div>
</div>
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
Ver Detalle
</button>
</div>
</div>
))}
</div>
)
}
export default App

17
frontend/src/index.css Normal file
View File

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

10
frontend/src/main.jsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

13
frontend/vite.config.js Normal file
View File

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

132
init-data.sh Normal file
View File

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