Compare commits

...

3 Commits

5 changed files with 178 additions and 58 deletions

View File

@@ -71,11 +71,18 @@ class Question(Base):
allow_photos = Column(Boolean, default=True) allow_photos = Column(Boolean, default=True)
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)
# Conditional logic
parent_question_id = Column(Integer, ForeignKey("questions.id"), nullable=True)
show_if_answer = Column(String(50), nullable=True) # Valor que dispara esta pregunta
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships # Relationships
checklist = relationship("Checklist", back_populates="questions") checklist = relationship("Checklist", back_populates="questions")
answers = relationship("Answer", back_populates="question") answers = relationship("Answer", back_populates="question")
subquestions = relationship("Question", backref="parent", remote_side=[id])
class Inspection(Base): class Inspection(Base):

View File

@@ -97,6 +97,8 @@ class QuestionBase(BaseModel):
allow_photos: bool = True allow_photos: bool = True
max_photos: int = 3 max_photos: int = 3
requires_comment_on_fail: bool = False requires_comment_on_fail: bool = False
parent_question_id: Optional[int] = None
show_if_answer: Optional[str] = None
class QuestionCreate(QuestionBase): class QuestionCreate(QuestionBase):
checklist_id: int checklist_id: int
@@ -113,6 +115,7 @@ class Question(QuestionBase):
from_attributes = True from_attributes = True
# Inspection Schemas # Inspection Schemas
class InspectionBase(BaseModel): class InspectionBase(BaseModel):
or_number: Optional[str] = None or_number: Optional[str] = None

View File

@@ -0,0 +1,45 @@
"""
Migration script to add conditional questions support
Run this script to add parent_question_id and show_if_answer columns
"""
from sqlalchemy import create_engine, text
import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://checklist_user:checklist_pass_2024@localhost:5432/checklist_db")
engine = create_engine(DATABASE_URL)
migrations = [
"""
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS parent_question_id INTEGER REFERENCES questions(id) ON DELETE CASCADE;
""",
"""
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS show_if_answer VARCHAR(50);
""",
"""
CREATE INDEX IF NOT EXISTS idx_questions_parent
ON questions(parent_question_id);
"""
]
def run_migration():
print("🔄 Starting migration for conditional questions...")
with engine.connect() as conn:
for i, migration in enumerate(migrations, 1):
try:
conn.execute(text(migration))
conn.commit()
print(f"✅ Migration {i}/{len(migrations)} completed")
except Exception as e:
print(f"❌ Error in migration {i}: {e}")
conn.rollback()
return False
print("✅ All migrations completed successfully!")
return True
if __name__ == "__main__":
run_migration()

View File

@@ -20,7 +20,7 @@ services:
retries: 5 retries: 5
backend: backend:
image: dymai/syntria-backend:1.0.11 image: dymai/syntria-backend:1.0.12
container_name: syntria-backend-prod container_name: syntria-backend-prod
restart: always restart: always
depends_on: depends_on:
@@ -38,7 +38,7 @@ services:
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
frontend: frontend:
image: dymai/syntria-frontend:1.0.17 image: dymai/syntria-frontend:1.0.18
container_name: syntria-frontend-prod container_name: syntria-frontend-prod
restart: always restart: always
depends_on: depends_on:

View File

@@ -2005,14 +2005,14 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
} }
} }
// Step 2: Submit answer and move to next question // Step 2: Auto-save answer when changed (non-blocking)
const handleAnswerSubmit = async () => { const saveAnswer = async (questionId) => {
if (!inspectionId || currentQuestionIndex >= questions.length) return if (!inspectionId) return
const question = questions[currentQuestionIndex] const question = questions.find(q => q.id === questionId)
const answer = answers[question.id] const answer = answers[questionId]
setLoading(true) if (!answer?.value) return // Don't save empty answers
try { try {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
@@ -2038,8 +2038,6 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
is_flagged: status === 'critical' is_flagged: status === 'critical'
} }
console.log('Submitting answer:', answerData)
const response = await fetch(`${API_URL}/api/answers`, { const response = await fetch(`${API_URL}/api/answers`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -2049,11 +2047,8 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
body: JSON.stringify(answerData) body: JSON.stringify(answerData)
}) })
console.log('Answer response status:', response.status)
if (response.ok) { if (response.ok) {
const savedAnswer = await response.json() const savedAnswer = await response.json()
console.log('Answer saved:', savedAnswer)
// Upload photos if any // Upload photos if any
if (answer.photos.length > 0) { if (answer.photos.length > 0) {
@@ -2069,25 +2064,57 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
} }
} }
// Move to next question or signatures // Mark as saved
if (currentQuestionIndex < questions.length - 1) { setAnswers({
setCurrentQuestionIndex(currentQuestionIndex + 1) ...answers,
} else { [questionId]: { ...answers[questionId], saved: true }
setStep(3) })
}
} else {
const errorText = await response.text()
console.error('Error response:', errorText)
alert('Error al guardar respuesta: ' + errorText)
} }
} catch (error) { } catch (error) {
console.error('Error:', error) console.error('Error saving answer:', error)
alert('Error al guardar respuesta: ' + error.message)
} finally {
setLoading(false)
} }
} }
// Navigate between questions freely
const goToQuestion = (index) => {
setCurrentQuestionIndex(index)
}
// Validate all questions answered before completing
const validateAllAnswered = () => {
const visibleQuestions = getVisibleQuestions()
const unanswered = visibleQuestions.filter(q => !answers[q.id]?.value)
return unanswered
}
// Get visible questions based on conditional logic
const getVisibleQuestions = () => {
return questions.filter(q => {
// If no parent, always visible
if (!q.parent_question_id) return true
// Check parent answer
const parentAnswer = answers[q.parent_question_id]
if (!parentAnswer) return false
// Show if parent answer matches trigger
return parentAnswer.value === q.show_if_answer
})
}
// Move to signatures step
const proceedToSignatures = () => {
const unanswered = validateAllAnswered()
if (unanswered.length > 0) {
alert(`⚠️ Faltan responder ${unanswered.length} pregunta(s). Por favor completa todas las preguntas antes de continuar.`)
// Go to first unanswered
const firstIndex = questions.findIndex(q => q.id === unanswered[0].id)
if (firstIndex >= 0) setCurrentQuestionIndex(firstIndex)
return
}
setStep(3)
}
// Step 3: Submit signatures and complete // Step 3: Submit signatures and complete
const handleComplete = async () => { const handleComplete = async () => {
setLoading(true) setLoading(true)
@@ -2352,14 +2379,24 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
</span> </span>
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
{step === 1 && 'Datos del Vehículo'} {step === 1 && 'Datos del Vehículo'}
{step === 2 && `Pregunta ${currentQuestionIndex + 1} de ${questions.length}`} {step === 2 && (() => {
const visible = getVisibleQuestions()
const answered = visible.filter(q => answers[q.id]?.value).length
return `${answered}/${visible.length} preguntas respondidas`
})()}
{step === 3 && 'Firmas'} {step === 3 && 'Firmas'}
</span> </span>
</div> </div>
<div className="w-full bg-gray-200 rounded-full h-2"> <div className="w-full bg-gray-200 rounded-full h-2">
<div <div
className="bg-blue-600 h-2 rounded-full transition-all" className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${(step / 3) * 100}%` }} style={{
width: step === 1 ? '33%' : step === 2 ? (() => {
const visible = getVisibleQuestions()
const answered = visible.filter(q => answers[q.id]?.value).length
return `${33 + (answered / visible.length) * 33}%`
})() : '100%'
}}
/> />
</div> </div>
</div> </div>
@@ -2513,10 +2550,13 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
type="radio" type="radio"
value="pass" value="pass"
checked={answers[currentQuestion.id]?.value === 'pass'} checked={answers[currentQuestion.id]?.value === 'pass'}
onChange={(e) => setAnswers({ onChange={(e) => {
...answers, setAnswers({
[currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value } ...answers,
})} [currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value }
})
setTimeout(() => saveAnswer(currentQuestion.id), 500)
}}
className="mr-2" className="mr-2"
/> />
<span className="text-green-600"> Pasa</span> <span className="text-green-600"> Pasa</span>
@@ -2526,10 +2566,13 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
type="radio" type="radio"
value="fail" value="fail"
checked={answers[currentQuestion.id]?.value === 'fail'} checked={answers[currentQuestion.id]?.value === 'fail'}
onChange={(e) => setAnswers({ onChange={(e) => {
...answers, setAnswers({
[currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value } ...answers,
})} [currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value }
})
setTimeout(() => saveAnswer(currentQuestion.id), 500)
}}
className="mr-2" className="mr-2"
/> />
<span className="text-red-600"> Falla</span> <span className="text-red-600"> Falla</span>
@@ -2540,10 +2583,13 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
{currentQuestion.type === 'good_bad' && ( {currentQuestion.type === 'good_bad' && (
<select <select
value={answers[currentQuestion.id]?.value} value={answers[currentQuestion.id]?.value}
onChange={(e) => setAnswers({ onChange={(e) => {
...answers, setAnswers({
[currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value } ...answers,
})} [currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value }
})
setTimeout(() => saveAnswer(currentQuestion.id), 500)
}}
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"
> >
<option value="">Seleccionar...</option> <option value="">Seleccionar...</option>
@@ -2659,29 +2705,48 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
</div> </div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
{currentQuestionIndex > 0 && ( <button
type="button"
onClick={() => {
if (currentQuestionIndex > 0) {
saveAnswer(currentQuestion.id)
goToQuestion(currentQuestionIndex - 1)
}
}}
disabled={currentQuestionIndex === 0}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
{currentQuestionIndex < getVisibleQuestions().length - 1 ? (
<button <button
type="button" onClick={() => {
onClick={() => setCurrentQuestionIndex(currentQuestionIndex - 1)} saveAnswer(currentQuestion.id)
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition" goToQuestion(currentQuestionIndex + 1)
}}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
> >
Anterior Siguiente
</button>
) : (
<button
onClick={() => {
saveAnswer(currentQuestion.id)
proceedToSignatures()
}}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Completar y Firmar
</button> </button>
)} )}
<button
onClick={handleAnswerSubmit}
disabled={loading || !answers[currentQuestion.id]?.value}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
title={!answers[currentQuestion.id]?.value ? 'Debe seleccionar una respuesta' : ''}
>
{loading ? 'Guardando...' : currentQuestionIndex < questions.length - 1 ? 'Siguiente →' : 'Continuar a Firmas'}
</button>
</div> </div>
{/* Debug info */} {/* Answer status indicator */}
{!answers[currentQuestion.id]?.value && ( {answers[currentQuestion.id]?.value && (
<div className="text-sm text-red-600 mt-2"> <div className="text-sm text-green-600 mt-2 flex items-center gap-1">
Debe seleccionar una respuesta para continuar <span></span>
<span>Respuesta guardada automáticamente</span>
</div> </div>
)} )}
</div> </div>