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:
2025-12-02 22:22:51 -03:00
parent 35b419a654
commit c4f5d960de
6 changed files with 109 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 */}

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