Base Principal del Proyecto
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal 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
54
.gitignore
vendored
Normal 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
386
README.md
Normal 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
27
backend/Dockerfile
Normal 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
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Backend app package
|
||||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Core package
|
||||||
27
backend/app/core/config.py
Normal file
27
backend/app/core/config.py
Normal 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()
|
||||||
21
backend/app/core/database.py
Normal file
21
backend/app/core/database.py
Normal 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()
|
||||||
30
backend/app/core/security.py
Normal file
30
backend/app/core/security.py
Normal 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
454
backend/app/main.py
Normal 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
146
backend/app/models.py
Normal 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
177
backend/app/schemas.py
Normal 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
14
backend/requirements.txt
Normal 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
52
docker-compose.yml
Normal 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
18
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
29
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
363
frontend/src/App.jsx
Normal file
363
frontend/src/App.jsx
Normal 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
17
frontend/src/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||||
|
)
|
||||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal 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
13
frontend/vite.config.js
Normal 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
132
init-data.sh
Normal 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 ""
|
||||||
Reference in New Issue
Block a user