✅ Nueva Funcionalidad: 3 Estados para Adjuntos (Ninguno/Opcional/Obligatorio)
He implementado el sistema de 3 estados para el requisito de fotos/archivos que solicitaste. Problema Original: Solo había 2 estados: ✅ Permitir fotos (checkbox activado) ❌ No permitir fotos (checkbox desactivado) Faltaba: Fotos opcionales vs obligatorias Solución Implementada: 3 Estados disponibles: 🚫 No permitir adjuntos (photo_requirement = 'none') No se muestra el input de fotos El mecánico NO puede adjuntar archivos 📎 Opcional (photo_requirement = 'optional') Se muestra el input de fotos El mecánico PUEDE adjuntar si lo desea No es obligatorio para continuar ⚠️ Obligatorio (photo_requirement = 'required') Se muestra el input de fotos con etiqueta "OBLIGATORIO" El mecánico DEBE adjuntar al menos 1 archivo Validación bloquea continuar sin adjuntos
This commit is contained in:
@@ -209,7 +209,7 @@ def send_completed_inspection_to_n8n(inspection, db):
|
|||||||
# No lanzamos excepción para no interrumpir el flujo normal
|
# No lanzamos excepción para no interrumpir el flujo normal
|
||||||
|
|
||||||
|
|
||||||
BACKEND_VERSION = "1.0.93"
|
BACKEND_VERSION = "1.0.94"
|
||||||
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
|
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
|
||||||
|
|
||||||
# S3/MinIO configuration
|
# S3/MinIO configuration
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ class Question(Base):
|
|||||||
points = Column(Integer, default=1)
|
points = Column(Integer, default=1)
|
||||||
options = Column(JSON) # Configuración flexible según tipo de pregunta
|
options = Column(JSON) # Configuración flexible según tipo de pregunta
|
||||||
order = Column(Integer, default=0)
|
order = Column(Integer, default=0)
|
||||||
allow_photos = Column(Boolean, default=True)
|
allow_photos = Column(Boolean, default=True) # DEPRECATED: usar photo_requirement
|
||||||
|
photo_requirement = Column(String(20), default='optional') # none, optional, required
|
||||||
max_photos = Column(Integer, default=3)
|
max_photos = Column(Integer, default=3)
|
||||||
requires_comment_on_fail = Column(Boolean, default=False)
|
requires_comment_on_fail = Column(Boolean, default=False)
|
||||||
send_notification = Column(Boolean, default=False)
|
send_notification = Column(Boolean, default=False)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "checklist-frontend",
|
"name": "checklist-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.99",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Service Worker para PWA con detección de actualizaciones
|
// Service Worker para PWA con detección de actualizaciones
|
||||||
// IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión
|
// IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión
|
||||||
const CACHE_NAME = 'ayutec-v1.0.99';
|
const CACHE_NAME = 'ayutec-v1.1.0';
|
||||||
const urlsToCache = [
|
const urlsToCache = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html'
|
'/index.html'
|
||||||
|
|||||||
@@ -1058,6 +1058,7 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
allow_photos: true,
|
allow_photos: true,
|
||||||
|
photo_requirement: 'optional',
|
||||||
max_photos: 3,
|
max_photos: 3,
|
||||||
requires_comment_on_fail: false,
|
requires_comment_on_fail: false,
|
||||||
send_notification: false,
|
send_notification: false,
|
||||||
@@ -1149,6 +1150,7 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
allow_photos: true,
|
allow_photos: true,
|
||||||
|
photo_requirement: 'optional',
|
||||||
max_photos: 3,
|
max_photos: 3,
|
||||||
requires_comment_on_fail: false,
|
requires_comment_on_fail: false,
|
||||||
send_notification: false,
|
send_notification: false,
|
||||||
@@ -1182,6 +1184,7 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
allow_photos: question.allow_photos ?? true,
|
allow_photos: question.allow_photos ?? true,
|
||||||
|
photo_requirement: question.photo_requirement || 'optional',
|
||||||
max_photos: question.max_photos || 3,
|
max_photos: question.max_photos || 3,
|
||||||
requires_comment_on_fail: question.requires_comment_on_fail || false,
|
requires_comment_on_fail: question.requires_comment_on_fail || false,
|
||||||
send_notification: question.send_notification || false,
|
send_notification: question.send_notification || false,
|
||||||
@@ -1212,6 +1215,7 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
points: parseInt(formData.points),
|
points: parseInt(formData.points),
|
||||||
options: formData.options,
|
options: formData.options,
|
||||||
allow_photos: formData.allow_photos,
|
allow_photos: formData.allow_photos,
|
||||||
|
photo_requirement: formData.photo_requirement,
|
||||||
max_photos: parseInt(formData.max_photos),
|
max_photos: parseInt(formData.max_photos),
|
||||||
requires_comment_on_fail: formData.requires_comment_on_fail,
|
requires_comment_on_fail: formData.requires_comment_on_fail,
|
||||||
send_notification: formData.send_notification,
|
send_notification: formData.send_notification,
|
||||||
@@ -1236,6 +1240,7 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
allow_photos: true,
|
allow_photos: true,
|
||||||
|
photo_requirement: 'optional',
|
||||||
max_photos: 3,
|
max_photos: 3,
|
||||||
requires_comment_on_fail: false,
|
requires_comment_on_fail: false,
|
||||||
send_notification: false,
|
send_notification: false,
|
||||||
@@ -1847,30 +1852,50 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
</div>
|
||||||
<input
|
|
||||||
type="checkbox"
|
{/* Configuración de fotos/archivos */}
|
||||||
checked={formData.allow_photos}
|
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||||
onChange={(e) => setFormData({ ...formData, allow_photos: e.target.checked })}
|
<h4 className="text-sm font-semibold text-indigo-900 mb-3">
|
||||||
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
📷 Fotos y Archivos Adjuntos
|
||||||
/>
|
</h4>
|
||||||
<label className="ml-2 text-sm text-gray-700">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
Permitir fotos
|
<div>
|
||||||
</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
</div>
|
Requisito de adjuntos
|
||||||
<div>
|
</label>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<select
|
||||||
Máx. fotos
|
value={formData.photo_requirement || 'optional'}
|
||||||
</label>
|
onChange={(e) => setFormData({ ...formData, photo_requirement: e.target.value })}
|
||||||
<input
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white"
|
||||||
type="number"
|
>
|
||||||
min="0"
|
<option value="none">🚫 No permitir adjuntos</option>
|
||||||
max="10"
|
<option value="optional">📎 Opcional (puede adjuntar si quiere)</option>
|
||||||
value={formData.max_photos}
|
<option value="required">⚠️ Obligatorio (debe adjuntar)</option>
|
||||||
onChange={(e) => setFormData({ ...formData, max_photos: parseInt(e.target.value) })}
|
</select>
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
disabled={!formData.allow_photos}
|
{formData.photo_requirement === 'none' && '• No se podrán adjuntar fotos/archivos'}
|
||||||
/>
|
{formData.photo_requirement === 'optional' && '• El mecánico puede adjuntar si lo desea'}
|
||||||
|
{formData.photo_requirement === 'required' && '• El mecánico DEBE adjuntar al menos 1 archivo'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Máx. archivos
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={formData.max_photos}
|
||||||
|
onChange={(e) => setFormData({ ...formData, max_photos: parseInt(e.target.value) })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||||
|
disabled={formData.photo_requirement === 'none'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Cantidad máxima de fotos/PDFs permitidos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1992,7 +2017,15 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
|||||||
{question.type}
|
{question.type}
|
||||||
</span>
|
</span>
|
||||||
<span>{question.points} pts</span>
|
<span>{question.points} pts</span>
|
||||||
{question.allow_photos && (
|
{question.photo_requirement === 'required' && (
|
||||||
|
<span className="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
|
||||||
|
⚠️ Fotos obligatorias
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{question.photo_requirement === 'optional' && (
|
||||||
|
<span>📷 Máx {question.max_photos} fotos</span>
|
||||||
|
)}
|
||||||
|
{(!question.photo_requirement || question.allow_photos) && !question.photo_requirement && (
|
||||||
<span>📷 Máx {question.max_photos} fotos</span>
|
<span>📷 Máx {question.max_photos} fotos</span>
|
||||||
)}
|
)}
|
||||||
{question.send_notification && (
|
{question.send_notification && (
|
||||||
@@ -4396,12 +4429,20 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
|
|||||||
const validateAllAnswered = () => {
|
const validateAllAnswered = () => {
|
||||||
const visibleQuestions = getVisibleQuestions()
|
const visibleQuestions = getVisibleQuestions()
|
||||||
const unanswered = visibleQuestions.filter(q => {
|
const unanswered = visibleQuestions.filter(q => {
|
||||||
|
const answer = answers[q.id]
|
||||||
|
|
||||||
// Para preguntas tipo photo_only, solo validar que tenga fotos
|
// Para preguntas tipo photo_only, solo validar que tenga fotos
|
||||||
if (q.options?.type === 'photo_only') {
|
if (q.options?.type === 'photo_only') {
|
||||||
return !answers[q.id]?.photos?.length
|
return !answer?.photos?.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validar fotos obligatorias (photo_requirement = 'required')
|
||||||
|
if (q.photo_requirement === 'required' && (!answer?.photos || answer.photos.length === 0)) {
|
||||||
|
return true // Falta adjuntar fotos obligatorias
|
||||||
|
}
|
||||||
|
|
||||||
// Para otros tipos, validar que tenga respuesta
|
// Para otros tipos, validar que tenga respuesta
|
||||||
return !answers[q.id]?.value
|
return !answer?.value
|
||||||
})
|
})
|
||||||
return unanswered
|
return unanswered
|
||||||
}
|
}
|
||||||
@@ -5088,13 +5129,23 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Photos */}
|
{/* Photos */}
|
||||||
{currentQuestion.allow_photos && (
|
{(currentQuestion.photo_requirement !== 'none' || currentQuestion.allow_photos) && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Fotografías / Documentos *
|
Fotografías / Documentos
|
||||||
|
{currentQuestion.photo_requirement === 'required' && (
|
||||||
|
<span className="ml-2 text-xs text-red-600 font-semibold">
|
||||||
|
⚠️ OBLIGATORIO
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{currentQuestion.photo_requirement === 'optional' && (
|
||||||
|
<span className="ml-2 text-xs text-gray-600">
|
||||||
|
(opcional)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{currentQuestion.max_photos && (
|
{currentQuestion.max_photos && (
|
||||||
<span className="ml-2 text-xs text-gray-600">
|
<span className="ml-2 text-xs text-gray-600">
|
||||||
(máximo {currentQuestion.max_photos} archivo{currentQuestion.max_photos > 1 ? 's' : ''})
|
- máx {currentQuestion.max_photos} archivo{currentQuestion.max_photos > 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && (
|
{(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && (
|
||||||
@@ -5115,7 +5166,7 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
|
|||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
disabled={aiAnalyzing}
|
disabled={aiAnalyzing}
|
||||||
required={!answers[currentQuestion.id]?.photos?.length}
|
required={currentQuestion.photo_requirement === 'required' && !answers[currentQuestion.id]?.photos?.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Photo Previews */}
|
{/* Photo Previews */}
|
||||||
|
|||||||
22
migrations/change_allow_photos_to_photo_requirement.sql
Normal file
22
migrations/change_allow_photos_to_photo_requirement.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- Migración: Cambiar allow_photos de Boolean a String con 3 estados
|
||||||
|
-- Fecha: 2025-12-02
|
||||||
|
-- Descripción: Agregar soporte para fotos opcionales/obligatorias/no permitidas
|
||||||
|
|
||||||
|
-- Paso 1: Agregar nueva columna
|
||||||
|
ALTER TABLE questions ADD COLUMN photo_requirement VARCHAR(20) DEFAULT 'optional';
|
||||||
|
|
||||||
|
-- Paso 2: Migrar datos existentes
|
||||||
|
UPDATE questions
|
||||||
|
SET photo_requirement = CASE
|
||||||
|
WHEN allow_photos = TRUE THEN 'optional'
|
||||||
|
WHEN allow_photos = FALSE THEN 'none'
|
||||||
|
ELSE 'optional'
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Paso 3: Eliminar columna antigua (opcional, comentar si quieres mantener compatibilidad)
|
||||||
|
-- ALTER TABLE questions DROP COLUMN allow_photos;
|
||||||
|
|
||||||
|
-- Nota: Los valores válidos son:
|
||||||
|
-- 'none' = No se permiten fotos
|
||||||
|
-- 'optional' = Fotos opcionales (puede adjuntar o no)
|
||||||
|
-- 'required' = Fotos obligatorias (debe adjuntar al menos 1)
|
||||||
Reference in New Issue
Block a user