Compare commits
3 Commits
e1ddd31968
...
78742dc906
| Author | SHA1 | Date | |
|---|---|---|---|
| 78742dc906 | |||
| 45086e8922 | |||
| c3ae68da4f |
@@ -71,11 +71,18 @@ class Question(Base):
|
||||
allow_photos = Column(Boolean, default=True)
|
||||
max_photos = Column(Integer, default=3)
|
||||
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())
|
||||
|
||||
# Relationships
|
||||
checklist = relationship("Checklist", back_populates="questions")
|
||||
answers = relationship("Answer", back_populates="question")
|
||||
subquestions = relationship("Question", backref="parent", remote_side=[id])
|
||||
|
||||
|
||||
|
||||
class Inspection(Base):
|
||||
|
||||
@@ -97,6 +97,8 @@ class QuestionBase(BaseModel):
|
||||
allow_photos: bool = True
|
||||
max_photos: int = 3
|
||||
requires_comment_on_fail: bool = False
|
||||
parent_question_id: Optional[int] = None
|
||||
show_if_answer: Optional[str] = None
|
||||
|
||||
class QuestionCreate(QuestionBase):
|
||||
checklist_id: int
|
||||
@@ -113,6 +115,7 @@ class Question(QuestionBase):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
|
||||
# Inspection Schemas
|
||||
class InspectionBase(BaseModel):
|
||||
or_number: Optional[str] = None
|
||||
|
||||
45
backend/migrate_conditional_questions.py
Normal file
45
backend/migrate_conditional_questions.py
Normal 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()
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
image: dymai/syntria-backend:1.0.11
|
||||
image: dymai/syntria-backend:1.0.12
|
||||
container_name: syntria-backend-prod
|
||||
restart: always
|
||||
depends_on:
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
frontend:
|
||||
image: dymai/syntria-frontend:1.0.17
|
||||
image: dymai/syntria-frontend:1.0.18
|
||||
container_name: syntria-frontend-prod
|
||||
restart: always
|
||||
depends_on:
|
||||
|
||||
@@ -2005,14 +2005,14 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Submit answer and move to next question
|
||||
const handleAnswerSubmit = async () => {
|
||||
if (!inspectionId || currentQuestionIndex >= questions.length) return
|
||||
// Step 2: Auto-save answer when changed (non-blocking)
|
||||
const saveAnswer = async (questionId) => {
|
||||
if (!inspectionId) return
|
||||
|
||||
const question = questions[currentQuestionIndex]
|
||||
const answer = answers[question.id]
|
||||
const question = questions.find(q => q.id === questionId)
|
||||
const answer = answers[questionId]
|
||||
|
||||
setLoading(true)
|
||||
if (!answer?.value) return // Don't save empty answers
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
@@ -2038,8 +2038,6 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
is_flagged: status === 'critical'
|
||||
}
|
||||
|
||||
console.log('Submitting answer:', answerData)
|
||||
|
||||
const response = await fetch(`${API_URL}/api/answers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -2049,11 +2047,8 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
body: JSON.stringify(answerData)
|
||||
})
|
||||
|
||||
console.log('Answer response status:', response.status)
|
||||
|
||||
if (response.ok) {
|
||||
const savedAnswer = await response.json()
|
||||
console.log('Answer saved:', savedAnswer)
|
||||
|
||||
// Upload photos if any
|
||||
if (answer.photos.length > 0) {
|
||||
@@ -2069,25 +2064,57 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next question or signatures
|
||||
if (currentQuestionIndex < questions.length - 1) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1)
|
||||
} else {
|
||||
setStep(3)
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
console.error('Error response:', errorText)
|
||||
alert('Error al guardar respuesta: ' + errorText)
|
||||
// Mark as saved
|
||||
setAnswers({
|
||||
...answers,
|
||||
[questionId]: { ...answers[questionId], saved: true }
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
alert('Error al guardar respuesta: ' + error.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
console.error('Error saving answer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
const handleComplete = async () => {
|
||||
setLoading(true)
|
||||
@@ -2352,14 +2379,24 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{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'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
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>
|
||||
@@ -2513,10 +2550,13 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
type="radio"
|
||||
value="pass"
|
||||
checked={answers[currentQuestion.id]?.value === 'pass'}
|
||||
onChange={(e) => setAnswers({
|
||||
onChange={(e) => {
|
||||
setAnswers({
|
||||
...answers,
|
||||
[currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value }
|
||||
})}
|
||||
})
|
||||
setTimeout(() => saveAnswer(currentQuestion.id), 500)
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-green-600">✓ Pasa</span>
|
||||
@@ -2526,10 +2566,13 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
type="radio"
|
||||
value="fail"
|
||||
checked={answers[currentQuestion.id]?.value === 'fail'}
|
||||
onChange={(e) => setAnswers({
|
||||
onChange={(e) => {
|
||||
setAnswers({
|
||||
...answers,
|
||||
[currentQuestion.id]: { ...answers[currentQuestion.id], value: e.target.value }
|
||||
})}
|
||||
})
|
||||
setTimeout(() => saveAnswer(currentQuestion.id), 500)
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-red-600">✗ Falla</span>
|
||||
@@ -2540,10 +2583,13 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
{currentQuestion.type === 'good_bad' && (
|
||||
<select
|
||||
value={answers[currentQuestion.id]?.value}
|
||||
onChange={(e) => setAnswers({
|
||||
onChange={(e) => {
|
||||
setAnswers({
|
||||
...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"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
@@ -2659,29 +2705,48 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
{currentQuestionIndex > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentQuestionIndex(currentQuestionIndex - 1)}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||||
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
|
||||
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' : ''}
|
||||
onClick={() => {
|
||||
saveAnswer(currentQuestion.id)
|
||||
goToQuestion(currentQuestionIndex + 1)
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
>
|
||||
{loading ? 'Guardando...' : currentQuestionIndex < questions.length - 1 ? 'Siguiente →' : 'Continuar a Firmas'}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Debug info */}
|
||||
{!answers[currentQuestion.id]?.value && (
|
||||
<div className="text-sm text-red-600 mt-2">
|
||||
⚠️ Debe seleccionar una respuesta para continuar
|
||||
{/* Answer status indicator */}
|
||||
{answers[currentQuestion.id]?.value && (
|
||||
<div className="text-sm text-green-600 mt-2 flex items-center gap-1">
|
||||
<span>✓</span>
|
||||
<span>Respuesta guardada automáticamente</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user