Cambios Grandes, editro nuevo de preguntas, logica nueva con mas opciones de pregutnas con preguntas hijos hasta 5 niveles
This commit is contained in:
@@ -65,18 +65,19 @@ class Question(Base):
|
||||
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
|
||||
type = Column(String(30), nullable=False) # boolean, single_choice, multiple_choice, scale, text, number, date, time
|
||||
points = Column(Integer, default=1)
|
||||
options = Column(JSON) # Para multiple choice
|
||||
options = Column(JSON) # Configuración flexible según tipo de pregunta
|
||||
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)
|
||||
send_notification = Column(Boolean, default=False)
|
||||
|
||||
# Conditional logic
|
||||
# Conditional logic - Subpreguntas anidadas hasta 5 niveles
|
||||
parent_question_id = Column(Integer, ForeignKey("questions.id"), nullable=True)
|
||||
show_if_answer = Column(String(50), nullable=True) # Valor que dispara esta pregunta
|
||||
depth_level = Column(Integer, default=0) # 0=principal, 1-5=subpreguntas anidadas
|
||||
|
||||
# AI Analysis
|
||||
ai_prompt = Column(Text, nullable=True) # Prompt personalizado para análisis de IA de esta pregunta
|
||||
|
||||
@@ -94,12 +94,27 @@ class Checklist(ChecklistBase):
|
||||
|
||||
|
||||
# Question Schemas
|
||||
# Tipos de preguntas soportados:
|
||||
# - boolean: Dos opciones personalizables (ej: Sí/No, Pasa/Falla)
|
||||
# - single_choice: Selección única con N opciones
|
||||
# - multiple_choice: Selección múltiple
|
||||
# - scale: Escala numérica (1-5, 1-10, etc.)
|
||||
# - text: Texto libre
|
||||
# - number: Valor numérico
|
||||
# - date: Fecha
|
||||
# - time: Hora
|
||||
|
||||
class QuestionBase(BaseModel):
|
||||
section: Optional[str] = None
|
||||
text: str
|
||||
type: str
|
||||
type: str # boolean, single_choice, multiple_choice, scale, text, number, date, time
|
||||
points: int = 1
|
||||
options: Optional[dict] = None
|
||||
options: Optional[dict] = None # Configuración flexible según tipo
|
||||
# Estructura de options:
|
||||
# Boolean: {"type": "boolean", "choices": [{"value": "yes", "label": "Sí", "points": 1, "status": "ok"}, ...]}
|
||||
# Single/Multiple Choice: {"type": "single_choice", "choices": [{"value": "opt1", "label": "Opción 1", "points": 2}, ...]}
|
||||
# Scale: {"type": "scale", "min": 1, "max": 5, "step": 1, "labels": {"min": "Muy malo", "max": "Excelente"}}
|
||||
# Text: {"type": "text", "multiline": true, "max_length": 500}
|
||||
order: int = 0
|
||||
allow_photos: bool = True
|
||||
max_photos: int = 3
|
||||
|
||||
@@ -2,6 +2,8 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-d
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import SignatureCanvas from 'react-signature-canvas'
|
||||
import Sidebar from './Sidebar'
|
||||
import QuestionTypeEditor from './QuestionTypeEditor'
|
||||
import QuestionAnswerInput from './QuestionAnswerInput'
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null)
|
||||
@@ -903,8 +905,15 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
||||
const [formData, setFormData] = useState({
|
||||
section: '',
|
||||
text: '',
|
||||
type: 'pass_fail',
|
||||
type: 'boolean',
|
||||
points: 1,
|
||||
options: {
|
||||
type: 'boolean',
|
||||
choices: [
|
||||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||||
]
|
||||
},
|
||||
allow_photos: true,
|
||||
max_photos: 3,
|
||||
requires_comment_on_fail: false,
|
||||
@@ -962,8 +971,15 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
||||
setFormData({
|
||||
section: '',
|
||||
text: '',
|
||||
type: 'pass_fail',
|
||||
type: 'boolean',
|
||||
points: 1,
|
||||
options: {
|
||||
type: 'boolean',
|
||||
choices: [
|
||||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||||
]
|
||||
},
|
||||
allow_photos: true,
|
||||
max_photos: 3,
|
||||
requires_comment_on_fail: false,
|
||||
@@ -1067,19 +1083,15 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo de pregunta *
|
||||
Puntos
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.points}
|
||||
onChange={(e) => setFormData({ ...formData, points: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
required
|
||||
>
|
||||
<option value="pass_fail">Pasa/Falla</option>
|
||||
<option value="good_bad">Bueno/Malo</option>
|
||||
<option value="text">Texto libre</option>
|
||||
<option value="number">Número</option>
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1097,9 +1109,27 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pregunta Condicional */}
|
||||
{/* Configuración del Tipo de Pregunta */}
|
||||
<div className="bg-white border-2 border-purple-300 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-purple-900 mb-3">📝 Configuración de la Pregunta</h4>
|
||||
<QuestionTypeEditor
|
||||
value={formData.options || null}
|
||||
onChange={(config) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
type: config.type,
|
||||
options: config
|
||||
})
|
||||
}}
|
||||
maxPoints={formData.points}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pregunta Condicional - Subpreguntas Anidadas hasta 5 niveles */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-3">⚡ Pregunta Condicional (opcional)</h4>
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-3">
|
||||
⚡ Pregunta Condicional - Subpreguntas Anidadas (hasta 5 niveles)
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
@@ -1109,22 +1139,33 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
||||
value={formData.parent_question_id || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
parent_question_id: e.target.value ? parseInt(e.target.value) : null
|
||||
parent_question_id: e.target.value ? parseInt(e.target.value) : null,
|
||||
show_if_answer: '' // Reset al cambiar padre
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white"
|
||||
>
|
||||
<option value="">Ninguna (pregunta principal)</option>
|
||||
{questions
|
||||
.filter(q => (q.type === 'pass_fail' || q.type === 'good_bad') && !q.parent_question_id)
|
||||
.map(q => (
|
||||
.filter(q => {
|
||||
// Permitir cualquier pregunta que no sea esta misma
|
||||
// y que tenga depth_level < 5 (para no exceder límite)
|
||||
const depth = q.depth_level || 0
|
||||
return depth < 5
|
||||
})
|
||||
.map(q => {
|
||||
const depth = q.depth_level || 0
|
||||
const indent = ' '.repeat(depth)
|
||||
const levelLabel = depth > 0 ? ` [Nivel ${depth}]` : ''
|
||||
return (
|
||||
<option key={q.id} value={q.id}>
|
||||
#{q.id} - {q.text.substring(0, 50)}{q.text.length > 50 ? '...' : ''}
|
||||
{indent}#{q.id} - {q.text.substring(0, 40)}{q.text.length > 40 ? '...' : ''}{levelLabel}
|
||||
</option>
|
||||
))
|
||||
)
|
||||
})
|
||||
}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Esta pregunta aparecerá solo si se responde la pregunta padre
|
||||
Esta pregunta aparecerá solo si se cumple la condición de la pregunta padre
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1140,25 +1181,58 @@ function QuestionsManagerModal({ checklist, onClose }) {
|
||||
<option value="">Seleccione...</option>
|
||||
{formData.parent_question_id && (() => {
|
||||
const parentQ = questions.find(q => q.id === formData.parent_question_id)
|
||||
if (parentQ?.type === 'pass_fail') {
|
||||
if (!parentQ) return null
|
||||
|
||||
// Leer opciones del nuevo formato
|
||||
const config = parentQ.options || {}
|
||||
const parentType = config.type || parentQ.type
|
||||
|
||||
// Para boolean o single_choice, mostrar las opciones configuradas
|
||||
if ((parentType === 'boolean' || parentType === 'single_choice') && config.choices) {
|
||||
return config.choices.map((choice, idx) => (
|
||||
<option key={idx} value={choice.value}>
|
||||
{choice.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
|
||||
// Compatibilidad con tipos antiguos
|
||||
if (parentType === 'pass_fail') {
|
||||
return [
|
||||
<option key="pass" value="pass">✓ Pasa</option>,
|
||||
<option key="fail" value="fail">✗ Falla</option>
|
||||
]
|
||||
} else if (parentQ?.type === 'good_bad') {
|
||||
} else if (parentType === 'good_bad') {
|
||||
return [
|
||||
<option key="good" value="good">✓ Bueno</option>,
|
||||
<option key="bad" value="bad">✗ Malo</option>
|
||||
]
|
||||
}
|
||||
return null
|
||||
|
||||
return <option disabled>Tipo de pregunta no compatible</option>
|
||||
})()}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
La pregunta solo se mostrará con esta respuesta
|
||||
La pregunta solo se mostrará con esta respuesta específica
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicador de profundidad */}
|
||||
{formData.parent_question_id && (() => {
|
||||
const parentQ = questions.find(q => q.id === formData.parent_question_id)
|
||||
const parentDepth = parentQ?.depth_level || 0
|
||||
const newDepth = parentDepth + 1
|
||||
|
||||
return (
|
||||
<div className={`mt-3 p-2 rounded ${newDepth >= 5 ? 'bg-red-50 border border-red-200' : 'bg-blue-100'}`}>
|
||||
<p className="text-xs">
|
||||
📊 <strong>Profundidad:</strong> Nivel {newDepth} de 5 máximo
|
||||
{newDepth >= 5 && ' ⚠️ Máximo alcanzado'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* AI Prompt - Solo visible si el checklist tiene IA habilitada */}
|
||||
@@ -2765,11 +2839,22 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
const token = localStorage.getItem('token')
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
// Determine status based on answer value
|
||||
// Determine status based on answer value and question config
|
||||
let status = 'ok'
|
||||
if (question.type === 'pass_fail') {
|
||||
const config = question.options || {}
|
||||
const questionType = config.type || question.type
|
||||
|
||||
if (questionType === 'boolean' && config.choices) {
|
||||
const selectedChoice = config.choices.find(c => c.value === answer.value)
|
||||
status = selectedChoice?.status || 'ok'
|
||||
} else if (questionType === 'single_choice' && config.choices) {
|
||||
const selectedChoice = config.choices.find(c => c.value === answer.value)
|
||||
status = selectedChoice?.status || 'ok'
|
||||
} else if (questionType === 'pass_fail') {
|
||||
// Compatibilidad hacia atrás
|
||||
status = answer.value === 'pass' ? 'ok' : 'critical'
|
||||
} else if (question.type === 'good_bad') {
|
||||
} else if (questionType === 'good_bad') {
|
||||
// Compatibilidad hacia atrás
|
||||
if (answer.value === 'good') status = 'ok'
|
||||
else if (answer.value === 'regular') status = 'warning'
|
||||
else if (answer.value === 'bad') status = 'critical'
|
||||
@@ -3360,107 +3445,17 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
|
||||
Respuesta *
|
||||
</label>
|
||||
|
||||
{currentQuestion.type === 'pass_fail' && (
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="pass"
|
||||
checked={answers[currentQuestion.id]?.value === 'pass'}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value
|
||||
<QuestionAnswerInput
|
||||
question={currentQuestion}
|
||||
value={answers[currentQuestion.id]?.value}
|
||||
onChange={(newValue) => {
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
|
||||
}))
|
||||
setTimeout(() => saveAnswer(currentQuestion.id), 500)
|
||||
}}
|
||||
className="mr-2"
|
||||
onSave={() => setTimeout(() => saveAnswer(currentQuestion.id), 500)}
|
||||
/>
|
||||
<span className="text-green-600">✓ Pasa</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="fail"
|
||||
checked={answers[currentQuestion.id]?.value === 'fail'}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
|
||||
}))
|
||||
setTimeout(() => saveAnswer(currentQuestion.id), 500)
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-red-600">✗ Falla</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentQuestion.type === 'good_bad' && (
|
||||
<select
|
||||
value={answers[currentQuestion.id]?.value}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: newValue }
|
||||
}))
|
||||
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>
|
||||
<option value="good">Bueno</option>
|
||||
<option value="regular">Regular</option>
|
||||
<option value="bad">Malo</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{currentQuestion.type === 'numeric' && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={answers[currentQuestion.id]?.value}
|
||||
onChange={(e) => setAnswers(prev => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: e.target.value }
|
||||
}))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Ingrese un valor numérico"
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentQuestion.type === 'status' && (
|
||||
<select
|
||||
value={answers[currentQuestion.id]?.value}
|
||||
onChange={(e) => setAnswers(prev => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: e.target.value }
|
||||
}))}
|
||||
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="ok">OK</option>
|
||||
<option value="warning">Advertencia</option>
|
||||
<option value="critical">Crítico</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{currentQuestion.type === 'text' && (
|
||||
<textarea
|
||||
value={answers[currentQuestion.id]?.value}
|
||||
onChange={(e) => setAnswers(prev => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: { ...prev[currentQuestion.id], value: e.target.value }
|
||||
}))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
rows="3"
|
||||
placeholder="Ingrese su respuesta"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Observations */}
|
||||
|
||||
302
frontend/src/QuestionAnswerInput.jsx
Normal file
302
frontend/src/QuestionAnswerInput.jsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Renderizador Dinámico de Campos de Respuesta
|
||||
* Renderiza el input apropiado según la configuración de la pregunta
|
||||
*/
|
||||
export function QuestionAnswerInput({ question, value, onChange, onSave }) {
|
||||
const config = question.options || {}
|
||||
const questionType = config.type || question.type
|
||||
|
||||
// BOOLEAN (2 opciones)
|
||||
if (questionType === 'boolean' && config.choices?.length === 2) {
|
||||
const [choice1, choice2] = config.choices
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center cursor-pointer px-4 py-3 border-2 rounded-lg transition hover:bg-gray-50">
|
||||
<input
|
||||
type="radio"
|
||||
value={choice1.value}
|
||||
checked={value === choice1.value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value)
|
||||
onSave?.()
|
||||
}}
|
||||
className="mr-3"
|
||||
/>
|
||||
<span className={`font-medium ${choice1.status === 'ok' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{choice1.status === 'ok' ? '✓' : '✗'} {choice1.label}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer px-4 py-3 border-2 rounded-lg transition hover:bg-gray-50">
|
||||
<input
|
||||
type="radio"
|
||||
value={choice2.value}
|
||||
checked={value === choice2.value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value)
|
||||
onSave?.()
|
||||
}}
|
||||
className="mr-3"
|
||||
/>
|
||||
<span className={`font-medium ${choice2.status === 'ok' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{choice2.status === 'ok' ? '✓' : '✗'} {choice2.label}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// SINGLE CHOICE (selección única)
|
||||
if (questionType === 'single_choice' && config.choices) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{config.choices.map((choice, idx) => (
|
||||
<label
|
||||
key={idx}
|
||||
className="flex items-center cursor-pointer px-4 py-3 border-2 rounded-lg transition hover:bg-gray-50"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value={choice.value}
|
||||
checked={value === choice.value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value)
|
||||
onSave?.()
|
||||
}}
|
||||
className="mr-3"
|
||||
/>
|
||||
<span className="flex-1 font-medium">{choice.label}</span>
|
||||
{choice.points > 0 && (
|
||||
<span className="text-sm text-blue-600">+{choice.points} pts</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
|
||||
{config.allow_other && (
|
||||
<div className="pl-7">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="__other__"
|
||||
checked={value && !config.choices.find(c => c.value === value)}
|
||||
onChange={(e) => onChange('')}
|
||||
className="mr-3"
|
||||
/>
|
||||
<span>Otro:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value && !config.choices.find(c => c.value === value) ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onSave}
|
||||
className="ml-2 flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Especificar..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// MULTIPLE CHOICE (selección múltiple)
|
||||
if (questionType === 'multiple_choice' && config.choices) {
|
||||
const selectedValues = value ? (Array.isArray(value) ? value : value.split(',')) : []
|
||||
|
||||
const handleToggle = (choiceValue) => {
|
||||
let newValues
|
||||
if (selectedValues.includes(choiceValue)) {
|
||||
newValues = selectedValues.filter(v => v !== choiceValue)
|
||||
} else {
|
||||
newValues = [...selectedValues, choiceValue]
|
||||
}
|
||||
onChange(newValues.join(','))
|
||||
onSave?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{config.choices.map((choice, idx) => (
|
||||
<label
|
||||
key={idx}
|
||||
className="flex items-center cursor-pointer px-4 py-3 border-2 rounded-lg transition hover:bg-gray-50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(choice.value)}
|
||||
onChange={() => handleToggle(choice.value)}
|
||||
className="mr-3 w-4 h-4"
|
||||
/>
|
||||
<span className="flex-1 font-medium">{choice.label}</span>
|
||||
{choice.points > 0 && (
|
||||
<span className="text-sm text-blue-600">+{choice.points} pts</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// SCALE (escala numérica)
|
||||
if (questionType === 'scale') {
|
||||
const min = config.min || 1
|
||||
const max = config.max || 5
|
||||
const step = config.step || 1
|
||||
const labels = config.labels || {}
|
||||
|
||||
const options = []
|
||||
for (let i = min; i <= max; i += step) {
|
||||
options.push(i)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
{labels.min && <span>{labels.min}</span>}
|
||||
{labels.max && <span>{labels.max}</span>}
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center">
|
||||
{options.map(num => (
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(String(num))
|
||||
onSave?.()
|
||||
}}
|
||||
className={`w-12 h-12 rounded-full font-bold transition ${
|
||||
value === String(num)
|
||||
? 'bg-blue-600 text-white scale-110'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{value && (
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 rounded-lg">
|
||||
<span className="text-sm text-gray-600">Seleccionado:</span>
|
||||
<span className="font-bold text-blue-600 text-lg">{value}</span>
|
||||
<span className="text-sm text-gray-600">/ {max}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// TEXT (texto libre)
|
||||
if (questionType === 'text') {
|
||||
const multiline = config.multiline !== false
|
||||
const maxLength = config.max_length || 500
|
||||
|
||||
if (multiline) {
|
||||
return (
|
||||
<div>
|
||||
<textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onSave}
|
||||
maxLength={maxLength}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
rows="4"
|
||||
placeholder="Ingrese su respuesta..."
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1 text-right">
|
||||
{(value?.length || 0)} / {maxLength} caracteres
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onSave}
|
||||
maxLength={maxLength}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Ingrese su respuesta..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NUMBER (valor numérico)
|
||||
if (questionType === 'number') {
|
||||
const min = config.min ?? 0
|
||||
const max = config.max ?? 100
|
||||
const unit = config.unit || ''
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onSave}
|
||||
min={min}
|
||||
max={max}
|
||||
step="any"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={`${min} - ${max}`}
|
||||
/>
|
||||
{unit && <span className="text-gray-600 font-medium">{unit}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DATE (fecha)
|
||||
if (questionType === 'date') {
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={value || ''}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value)
|
||||
onSave?.()
|
||||
}}
|
||||
min={config.min_date}
|
||||
max={config.max_date}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// TIME (hora)
|
||||
if (questionType === 'time') {
|
||||
return (
|
||||
<input
|
||||
type="time"
|
||||
value={value || ''}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value)
|
||||
onSave?.()
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback para tipos desconocidos
|
||||
return (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
⚠️ Tipo de pregunta no reconocido: <code>{questionType}</code>
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onSave}
|
||||
className="mt-2 w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||
placeholder="Respuesta de texto libre..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionAnswerInput
|
||||
530
frontend/src/QuestionTypeEditor.jsx
Normal file
530
frontend/src/QuestionTypeEditor.jsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Editor de Tipos de Preguntas Configurables (estilo Google Forms)
|
||||
*
|
||||
* Tipos soportados:
|
||||
* - boolean: Dos opciones personalizables
|
||||
* - single_choice: Selección única con N opciones
|
||||
* - multiple_choice: Selección múltiple
|
||||
* - scale: Escala numérica
|
||||
* - text: Texto libre
|
||||
* - number: Valor numérico
|
||||
* - date: Fecha
|
||||
* - time: Hora
|
||||
*/
|
||||
|
||||
const QUESTION_TYPES = [
|
||||
{ value: 'boolean', label: '✓✗ Booleana (2 opciones)', icon: '🔘' },
|
||||
{ value: 'single_choice', label: '◎ Selección Única', icon: '⚪' },
|
||||
{ value: 'multiple_choice', label: '☑ Selección Múltiple', icon: '✅' },
|
||||
{ value: 'scale', label: '⭐ Escala Numérica', icon: '📊' },
|
||||
{ value: 'text', label: '📝 Texto Libre', icon: '✏️' },
|
||||
{ value: 'number', label: '🔢 Número', icon: '#️⃣' },
|
||||
{ value: 'date', label: '📅 Fecha', icon: '📆' },
|
||||
{ value: 'time', label: '🕐 Hora', icon: '⏰' }
|
||||
]
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'ok', label: 'OK (Verde)', color: 'green' },
|
||||
{ value: 'warning', label: 'Advertencia (Amarillo)', color: 'yellow' },
|
||||
{ value: 'critical', label: 'Crítico (Rojo)', color: 'red' },
|
||||
{ value: 'info', label: 'Informativo (Azul)', color: 'blue' }
|
||||
]
|
||||
|
||||
// Plantillas predefinidas para tipos booleanos
|
||||
const BOOLEAN_TEMPLATES = [
|
||||
{
|
||||
name: 'Pasa/Falla',
|
||||
choices: [
|
||||
{ value: 'pass', label: 'Pasa', points: 1, status: 'ok' },
|
||||
{ value: 'fail', label: 'Falla', points: 0, status: 'critical' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Sí/No',
|
||||
choices: [
|
||||
{ value: 'yes', label: 'Sí', points: 1, status: 'ok' },
|
||||
{ value: 'no', label: 'No', points: 0, status: 'critical' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Bueno/Malo',
|
||||
choices: [
|
||||
{ value: 'good', label: 'Bueno', points: 1, status: 'ok' },
|
||||
{ value: 'bad', label: 'Malo', points: 0, status: 'critical' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Aprobado/Rechazado',
|
||||
choices: [
|
||||
{ value: 'approved', label: 'Aprobado', points: 1, status: 'ok' },
|
||||
{ value: 'rejected', label: 'Rechazado', points: 0, status: 'critical' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Funciona/No Funciona',
|
||||
choices: [
|
||||
{ value: 'works', label: 'Funciona', points: 1, status: 'ok' },
|
||||
{ value: 'not_works', label: 'No Funciona', points: 0, status: 'critical' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Personalizado',
|
||||
choices: [
|
||||
{ value: 'option1', label: 'Opción 1', points: 1, status: 'ok' },
|
||||
{ value: 'option2', label: 'Opción 2', points: 0, status: 'critical' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export function QuestionTypeEditor({ value, onChange, maxPoints = 1 }) {
|
||||
const [config, setConfig] = useState(value || {
|
||||
type: 'boolean',
|
||||
choices: BOOLEAN_TEMPLATES[0].choices
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (value && value.type) {
|
||||
setConfig(value)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const handleTypeChange = (newType) => {
|
||||
let newConfig = { type: newType }
|
||||
|
||||
// Inicializar con valores por defecto según el tipo
|
||||
switch (newType) {
|
||||
case 'boolean':
|
||||
newConfig.choices = [...BOOLEAN_TEMPLATES[0].choices]
|
||||
break
|
||||
case 'single_choice':
|
||||
case 'multiple_choice':
|
||||
newConfig.choices = [
|
||||
{ value: 'option1', label: 'Opción 1', points: maxPoints },
|
||||
{ value: 'option2', label: 'Opción 2', points: Math.floor(maxPoints / 2) },
|
||||
{ value: 'option3', label: 'Opción 3', points: 0 }
|
||||
]
|
||||
newConfig.allow_other = false
|
||||
break
|
||||
case 'scale':
|
||||
newConfig.min = 1
|
||||
newConfig.max = 5
|
||||
newConfig.step = 1
|
||||
newConfig.labels = { min: 'Muy malo', max: 'Excelente' }
|
||||
newConfig.points_per_level = maxPoints / 5
|
||||
break
|
||||
case 'text':
|
||||
newConfig.multiline = true
|
||||
newConfig.max_length = 500
|
||||
break
|
||||
case 'number':
|
||||
newConfig.min = 0
|
||||
newConfig.max = 100
|
||||
newConfig.unit = ''
|
||||
break
|
||||
case 'date':
|
||||
newConfig.min_date = null
|
||||
newConfig.max_date = null
|
||||
break
|
||||
case 'time':
|
||||
newConfig.format = '24h'
|
||||
break
|
||||
}
|
||||
|
||||
setConfig(newConfig)
|
||||
onChange(newConfig)
|
||||
}
|
||||
|
||||
const updateConfig = (updates) => {
|
||||
const newConfig = { ...config, ...updates }
|
||||
setConfig(newConfig)
|
||||
onChange(newConfig)
|
||||
}
|
||||
|
||||
const updateChoice = (index, field, value) => {
|
||||
const newChoices = [...config.choices]
|
||||
newChoices[index] = { ...newChoices[index], [field]: value }
|
||||
updateConfig({ choices: newChoices })
|
||||
}
|
||||
|
||||
const addChoice = () => {
|
||||
const newChoices = [...config.choices, {
|
||||
value: `option${config.choices.length + 1}`,
|
||||
label: `Opción ${config.choices.length + 1}`,
|
||||
points: 0
|
||||
}]
|
||||
updateConfig({ choices: newChoices })
|
||||
}
|
||||
|
||||
const removeChoice = (index) => {
|
||||
if (config.type === 'boolean' && config.choices.length <= 2) {
|
||||
alert('Las preguntas booleanas deben tener exactamente 2 opciones')
|
||||
return
|
||||
}
|
||||
const newChoices = config.choices.filter((_, i) => i !== index)
|
||||
updateConfig({ choices: newChoices })
|
||||
}
|
||||
|
||||
const applyBooleanTemplate = (template) => {
|
||||
updateConfig({ choices: [...template.choices] })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Selector de Tipo de Pregunta */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tipo de Pregunta
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{QUESTION_TYPES.map(type => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => handleTypeChange(type.value)}
|
||||
className={`p-3 border-2 rounded-lg text-left transition ${
|
||||
config.type === type.value
|
||||
? 'border-purple-600 bg-purple-50'
|
||||
: 'border-gray-300 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{type.icon}</div>
|
||||
<div className="text-xs font-medium">{type.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuración específica según tipo */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
{/* BOOLEAN */}
|
||||
{config.type === 'boolean' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Plantilla Predefinida
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{BOOLEAN_TEMPLATES.map((template, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => applyBooleanTemplate(template)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg hover:border-purple-500 hover:bg-purple-50 text-sm transition"
|
||||
>
|
||||
{template.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{config.choices?.map((choice, idx) => (
|
||||
<div key={idx} className="bg-white border border-gray-300 rounded-lg p-3">
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
Opción {idx + 1}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={choice.label}
|
||||
onChange={(e) => updateChoice(idx, 'label', e.target.value)}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded mb-2 text-sm"
|
||||
placeholder="Texto de la opción"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Puntos</label>
|
||||
<input
|
||||
type="number"
|
||||
value={choice.points}
|
||||
onChange={(e) => updateChoice(idx, 'points', parseInt(e.target.value) || 0)}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Estado</label>
|
||||
<select
|
||||
value={choice.status || 'info'}
|
||||
onChange={(e) => updateChoice(idx, 'status', e.target.value)}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
>
|
||||
{STATUS_OPTIONS.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SINGLE CHOICE / MULTIPLE CHOICE */}
|
||||
{(config.type === 'single_choice' || config.type === 'multiple_choice') && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Opciones ({config.choices?.length || 0})
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addChoice}
|
||||
className="px-3 py-1 bg-purple-600 text-white rounded text-sm hover:bg-purple-700"
|
||||
>
|
||||
+ Agregar Opción
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{config.choices?.map((choice, idx) => (
|
||||
<div key={idx} className="flex gap-2 items-start bg-white border border-gray-300 rounded-lg p-2">
|
||||
<div className="flex-shrink-0 mt-2">
|
||||
{config.type === 'single_choice' ? '⚪' : '☑️'}
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2">
|
||||
<input
|
||||
type="text"
|
||||
value={choice.label}
|
||||
onChange={(e) => updateChoice(idx, 'label', e.target.value)}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
placeholder={`Opción ${idx + 1}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
value={choice.points}
|
||||
onChange={(e) => updateChoice(idx, 'points', parseInt(e.target.value) || 0)}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
placeholder="Puntos"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeChoice(idx)}
|
||||
className="flex-shrink-0 px-2 py-1 text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allow_other"
|
||||
checked={config.allow_other || false}
|
||||
onChange={(e) => updateConfig({ allow_other: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="allow_other" className="text-sm text-gray-700">
|
||||
Permitir opción "Otro" con texto libre
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SCALE */}
|
||||
{config.type === 'scale' && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Mínimo</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.min || 1}
|
||||
onChange={(e) => updateConfig({ min: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Máximo</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.max || 5}
|
||||
onChange={(e) => updateConfig({ max: parseInt(e.target.value) || 5 })}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Incremento</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.step || 1}
|
||||
onChange={(e) => updateConfig({ step: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Etiqueta Mínimo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.labels?.min || ''}
|
||||
onChange={(e) => updateConfig({
|
||||
labels: { ...config.labels, min: e.target.value }
|
||||
})}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
placeholder="Ej: Muy malo"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Etiqueta Máximo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.labels?.max || ''}
|
||||
onChange={(e) => updateConfig({
|
||||
labels: { ...config.labels, max: e.target.value }
|
||||
})}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
placeholder="Ej: Excelente"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded p-3">
|
||||
<p className="text-xs text-blue-800">
|
||||
Vista previa: {config.min} {config.labels?.min} ⭐⭐⭐⭐⭐ {config.max} {config.labels?.max}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TEXT */}
|
||||
{config.type === 'text' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="multiline"
|
||||
checked={config.multiline !== false}
|
||||
onChange={(e) => updateConfig({ multiline: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="multiline" className="text-sm text-gray-700">
|
||||
Permitir múltiples líneas
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
Longitud máxima (caracteres)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.max_length || 500}
|
||||
onChange={(e) => updateConfig({ max_length: parseInt(e.target.value) || 500 })}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
min="1"
|
||||
max="5000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NUMBER */}
|
||||
{config.type === 'number' && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Mínimo</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.min ?? 0}
|
||||
onChange={(e) => updateConfig({ min: parseFloat(e.target.value) })}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Máximo</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.max ?? 100}
|
||||
onChange={(e) => updateConfig({ max: parseFloat(e.target.value) })}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Unidad</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.unit || ''}
|
||||
onChange={(e) => updateConfig({ unit: e.target.value })}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
placeholder="Ej: km, kg, °C"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DATE */}
|
||||
{config.type === 'date' && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Fecha mínima (opcional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={config.min_date || ''}
|
||||
onChange={(e) => updateConfig({ min_date: e.target.value })}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Fecha máxima (opcional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={config.max_date || ''}
|
||||
onChange={(e) => updateConfig({ max_date: e.target.value })}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TIME */}
|
||||
{config.type === 'time' && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-2">Formato</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="12h"
|
||||
checked={config.format === '12h'}
|
||||
onChange={(e) => updateConfig({ format: e.target.value })}
|
||||
className="border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">12 horas (AM/PM)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="24h"
|
||||
checked={config.format !== '12h'}
|
||||
onChange={(e) => updateConfig({ format: e.target.value })}
|
||||
className="border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">24 horas</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuestionTypeEditor
|
||||
153
migrations/add_flexible_question_types.sql
Normal file
153
migrations/add_flexible_question_types.sql
Normal file
@@ -0,0 +1,153 @@
|
||||
-- Migración: Sistema de tipos de preguntas configurables (estilo Google Forms)
|
||||
-- Fecha: 2025-11-25
|
||||
-- Descripción: Permite crear tipos de preguntas personalizables con opciones definidas por el usuario
|
||||
|
||||
-- PASO 1: Agregar comentario explicativo a la columna options
|
||||
COMMENT ON COLUMN questions.options IS 'Configuración JSON de la pregunta con choices personalizables. Ejemplos:
|
||||
Boolean: {"type": "boolean", "choices": [{"value": "yes", "label": "Sí", "points": 1, "status": "ok"}, {"value": "no", "label": "No", "points": 0, "status": "critical"}]}
|
||||
Single Choice: {"type": "single_choice", "choices": [{"value": "excellent", "label": "Excelente", "points": 3}, {"value": "good", "label": "Bueno", "points": 2}]}
|
||||
Multiple Choice: {"type": "multiple_choice", "choices": [{"value": "lights", "label": "Luces"}, {"value": "wipers", "label": "Limpiaparabrisas"}]}
|
||||
Scale: {"type": "scale", "min": 1, "max": 5, "step": 1, "labels": {"min": "Muy malo", "max": "Excelente"}}';
|
||||
|
||||
-- PASO 2: Actualizar el comentario de la columna type
|
||||
COMMENT ON COLUMN questions.type IS 'Tipo de pregunta:
|
||||
- boolean: Dos opciones personalizables (ej: Sí/No, Pasa/Falla, Bueno/Malo)
|
||||
- single_choice: Selección única con opciones personalizadas
|
||||
- multiple_choice: Selección múltiple con opciones personalizadas
|
||||
- scale: Escala numérica personalizable (1-5, 1-10, etc.)
|
||||
- text: Texto libre
|
||||
- number: Valor numérico
|
||||
- date: Fecha
|
||||
- time: Hora';
|
||||
|
||||
-- PASO 3: Migrar datos existentes de pass_fail y good_bad al nuevo formato
|
||||
-- Actualizar preguntas tipo pass_fail
|
||||
UPDATE questions
|
||||
SET
|
||||
type = 'boolean',
|
||||
options = jsonb_build_object(
|
||||
'type', 'boolean',
|
||||
'choices', jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'value', 'pass',
|
||||
'label', 'Pasa',
|
||||
'points', points,
|
||||
'status', 'ok'
|
||||
),
|
||||
jsonb_build_object(
|
||||
'value', 'fail',
|
||||
'label', 'Falla',
|
||||
'points', 0,
|
||||
'status', 'critical'
|
||||
)
|
||||
)
|
||||
)
|
||||
WHERE type = 'pass_fail';
|
||||
|
||||
-- Actualizar preguntas tipo good_bad
|
||||
UPDATE questions
|
||||
SET
|
||||
type = 'boolean',
|
||||
options = jsonb_build_object(
|
||||
'type', 'boolean',
|
||||
'choices', jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'value', 'good',
|
||||
'label', 'Bueno',
|
||||
'points', points,
|
||||
'status', 'ok'
|
||||
),
|
||||
jsonb_build_object(
|
||||
'value', 'bad',
|
||||
'label', 'Malo',
|
||||
'points', 0,
|
||||
'status', 'critical'
|
||||
)
|
||||
)
|
||||
)
|
||||
WHERE type = 'good_bad';
|
||||
|
||||
-- Actualizar preguntas tipo good_bad_regular (3 opciones)
|
||||
UPDATE questions
|
||||
SET
|
||||
type = 'single_choice',
|
||||
options = jsonb_build_object(
|
||||
'type', 'single_choice',
|
||||
'choices', jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'value', 'good',
|
||||
'label', 'Bueno',
|
||||
'points', points,
|
||||
'status', 'ok'
|
||||
),
|
||||
jsonb_build_object(
|
||||
'value', 'regular',
|
||||
'label', 'Regular',
|
||||
'points', FLOOR(points / 2),
|
||||
'status', 'warning'
|
||||
),
|
||||
jsonb_build_object(
|
||||
'value', 'bad',
|
||||
'label', 'Malo',
|
||||
'points', 0,
|
||||
'status', 'critical'
|
||||
)
|
||||
)
|
||||
)
|
||||
WHERE type = 'good_bad_regular';
|
||||
|
||||
-- PASO 4: Actualizar preguntas de tipo text sin opciones
|
||||
UPDATE questions
|
||||
SET
|
||||
options = jsonb_build_object(
|
||||
'type', 'text',
|
||||
'multiline', true,
|
||||
'max_length', 500
|
||||
)
|
||||
WHERE type = 'text' AND (options IS NULL OR options::text = '{}');
|
||||
|
||||
-- PASO 5: Crear función helper para validar estructura de options
|
||||
CREATE OR REPLACE FUNCTION validate_question_options()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Validar que options tenga el campo type
|
||||
IF NEW.options IS NOT NULL AND NOT (NEW.options ? 'type') THEN
|
||||
RAISE EXCEPTION 'El campo options debe contener una propiedad "type"';
|
||||
END IF;
|
||||
|
||||
-- Validar que boolean y single_choice tengan choices
|
||||
IF NEW.type IN ('boolean', 'single_choice', 'multiple_choice') THEN
|
||||
IF NEW.options IS NULL OR NOT (NEW.options ? 'choices') THEN
|
||||
RAISE EXCEPTION 'Las preguntas de tipo % requieren un array "choices" en options', NEW.type;
|
||||
END IF;
|
||||
|
||||
-- Validar que boolean tenga exactamente 2 opciones
|
||||
IF NEW.type = 'boolean' AND jsonb_array_length(NEW.options->'choices') != 2 THEN
|
||||
RAISE EXCEPTION 'Las preguntas de tipo boolean deben tener exactamente 2 opciones';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- PASO 6: Crear trigger para validación (opcional, comentado por si causa problemas)
|
||||
-- DROP TRIGGER IF EXISTS validate_options_trigger ON questions;
|
||||
-- CREATE TRIGGER validate_options_trigger
|
||||
-- BEFORE INSERT OR UPDATE ON questions
|
||||
-- FOR EACH ROW
|
||||
-- EXECUTE FUNCTION validate_question_options();
|
||||
|
||||
-- PASO 7: Índice para búsquedas por tipo de pregunta
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_type ON questions(type);
|
||||
|
||||
-- Verificación
|
||||
SELECT
|
||||
type,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN options IS NOT NULL THEN 1 END) as with_options
|
||||
FROM questions
|
||||
GROUP BY type
|
||||
ORDER BY type;
|
||||
|
||||
SELECT 'Migración de tipos de preguntas flexibles completada exitosamente' AS status;
|
||||
172
migrations/add_nested_subquestions.sql
Normal file
172
migrations/add_nested_subquestions.sql
Normal file
@@ -0,0 +1,172 @@
|
||||
-- Migración: Subpreguntas anidadas hasta 5 niveles
|
||||
-- Fecha: 2025-11-25
|
||||
-- Descripción: Agrega soporte y validación para subpreguntas anidadas hasta 5 niveles de profundidad
|
||||
|
||||
-- Agregar columna para tracking de nivel (opcional pero útil)
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN IF NOT EXISTS depth_level INTEGER DEFAULT 0;
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON COLUMN questions.parent_question_id IS 'ID de la pregunta padre. NULL = pregunta principal. Soporta anidamiento hasta 5 niveles.';
|
||||
COMMENT ON COLUMN questions.depth_level IS 'Nivel de profundidad: 0=principal, 1-5=subpreguntas anidadas';
|
||||
|
||||
-- Función para calcular profundidad de una pregunta
|
||||
CREATE OR REPLACE FUNCTION calculate_question_depth(question_id INTEGER)
|
||||
RETURNS INTEGER AS $$
|
||||
DECLARE
|
||||
current_parent_id INTEGER;
|
||||
depth INTEGER := 0;
|
||||
max_iterations INTEGER := 10; -- Protección contra loops infinitos
|
||||
BEGIN
|
||||
-- Obtener el parent_id de la pregunta
|
||||
SELECT parent_question_id INTO current_parent_id
|
||||
FROM questions
|
||||
WHERE id = question_id;
|
||||
|
||||
-- Si no tiene padre, es nivel 0
|
||||
IF current_parent_id IS NULL THEN
|
||||
RETURN 0;
|
||||
END IF;
|
||||
|
||||
-- Subir por la jerarquía contando niveles
|
||||
WHILE current_parent_id IS NOT NULL AND depth < max_iterations LOOP
|
||||
depth := depth + 1;
|
||||
|
||||
SELECT parent_question_id INTO current_parent_id
|
||||
FROM questions
|
||||
WHERE id = current_parent_id;
|
||||
END LOOP;
|
||||
|
||||
RETURN depth;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Función trigger para validar profundidad máxima
|
||||
CREATE OR REPLACE FUNCTION validate_question_depth()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
calculated_depth INTEGER;
|
||||
parent_depth INTEGER;
|
||||
BEGIN
|
||||
-- Si no tiene padre, es nivel 0
|
||||
IF NEW.parent_question_id IS NULL THEN
|
||||
NEW.depth_level := 0;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Validar que el padre existe y no es la misma pregunta
|
||||
IF NEW.parent_question_id = NEW.id THEN
|
||||
RAISE EXCEPTION 'Una pregunta no puede ser su propio padre';
|
||||
END IF;
|
||||
|
||||
-- Calcular profundidad del padre
|
||||
SELECT depth_level INTO parent_depth
|
||||
FROM questions
|
||||
WHERE id = NEW.parent_question_id;
|
||||
|
||||
IF parent_depth IS NULL THEN
|
||||
-- Si el padre no tiene depth_level, calcularlo
|
||||
parent_depth := calculate_question_depth(NEW.parent_question_id);
|
||||
END IF;
|
||||
|
||||
-- La nueva pregunta es un nivel más profundo que su padre
|
||||
calculated_depth := parent_depth + 1;
|
||||
|
||||
-- Validar que no excede 5 niveles
|
||||
IF calculated_depth > 5 THEN
|
||||
RAISE EXCEPTION 'No se permiten subpreguntas con profundidad mayor a 5. Esta pregunta tendría profundidad %, máximo permitido: 5', calculated_depth;
|
||||
END IF;
|
||||
|
||||
-- Asignar el nivel calculado
|
||||
NEW.depth_level := calculated_depth;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Crear trigger para INSERT y UPDATE
|
||||
DROP TRIGGER IF EXISTS validate_depth_trigger ON questions;
|
||||
CREATE TRIGGER validate_depth_trigger
|
||||
BEFORE INSERT OR UPDATE OF parent_question_id ON questions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_question_depth();
|
||||
|
||||
-- Actualizar depth_level para preguntas existentes
|
||||
UPDATE questions
|
||||
SET depth_level = calculate_question_depth(id);
|
||||
|
||||
-- Crear índice para mejorar queries de subpreguntas
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_parent ON questions(parent_question_id) WHERE parent_question_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_depth ON questions(depth_level);
|
||||
|
||||
-- Función helper para obtener árbol de subpreguntas
|
||||
CREATE OR REPLACE FUNCTION get_question_tree(root_question_id INTEGER)
|
||||
RETURNS TABLE (
|
||||
id INTEGER,
|
||||
parent_question_id INTEGER,
|
||||
text TEXT,
|
||||
type VARCHAR(30),
|
||||
depth_level INTEGER,
|
||||
show_if_answer VARCHAR(50),
|
||||
path TEXT
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH RECURSIVE question_tree AS (
|
||||
-- Pregunta raíz
|
||||
SELECT
|
||||
q.id,
|
||||
q.parent_question_id,
|
||||
q.text,
|
||||
q.type,
|
||||
q.depth_level,
|
||||
q.show_if_answer,
|
||||
q.id::TEXT as path
|
||||
FROM questions q
|
||||
WHERE q.id = root_question_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Subpreguntas recursivas
|
||||
SELECT
|
||||
q.id,
|
||||
q.parent_question_id,
|
||||
q.text,
|
||||
q.type,
|
||||
q.depth_level,
|
||||
q.show_if_answer,
|
||||
qt.path || ' > ' || q.id::TEXT
|
||||
FROM questions q
|
||||
INNER JOIN question_tree qt ON q.parent_question_id = qt.id
|
||||
WHERE q.depth_level <= 5 -- Límite de seguridad
|
||||
)
|
||||
SELECT * FROM question_tree
|
||||
ORDER BY depth_level, id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Verificar estructura actual
|
||||
SELECT
|
||||
COUNT(*) as total_preguntas,
|
||||
COUNT(CASE WHEN parent_question_id IS NULL THEN 1 END) as principales,
|
||||
COUNT(CASE WHEN parent_question_id IS NOT NULL THEN 1 END) as subpreguntas,
|
||||
MAX(depth_level) as max_profundidad
|
||||
FROM questions;
|
||||
|
||||
-- Ver distribución por profundidad
|
||||
SELECT
|
||||
depth_level,
|
||||
COUNT(*) as cantidad,
|
||||
CASE
|
||||
WHEN depth_level = 0 THEN 'Principales'
|
||||
WHEN depth_level = 1 THEN 'Nivel 1'
|
||||
WHEN depth_level = 2 THEN 'Nivel 2'
|
||||
WHEN depth_level = 3 THEN 'Nivel 3'
|
||||
WHEN depth_level = 4 THEN 'Nivel 4'
|
||||
WHEN depth_level = 5 THEN 'Nivel 5'
|
||||
END as descripcion
|
||||
FROM questions
|
||||
GROUP BY depth_level
|
||||
ORDER BY depth_level;
|
||||
|
||||
SELECT '✓ Migración de subpreguntas anidadas completada' AS status;
|
||||
468
migrations/migrate_question_types.py
Normal file
468
migrations/migrate_question_types.py
Normal file
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script de Migración de Tipos de Preguntas
|
||||
==========================================
|
||||
Migra preguntas existentes (pass_fail, good_bad) al nuevo formato configurable.
|
||||
|
||||
Requisitos:
|
||||
pip install psycopg2-binary
|
||||
|
||||
Uso:
|
||||
python migrate_question_types.py
|
||||
|
||||
Base de Datos:
|
||||
Host: portianerp.rshtech.com.py
|
||||
Database: syntria_db
|
||||
User: syntria_user
|
||||
"""
|
||||
|
||||
import psycopg2
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any
|
||||
|
||||
# Configuración de la base de datos
|
||||
DB_CONFIG = {
|
||||
'host': 'portianerp.rshtech.com.py',
|
||||
'database': 'syntria_db',
|
||||
'user': 'syntria_user',
|
||||
'password': 'syntria_secure_2024',
|
||||
'port': 5432
|
||||
}
|
||||
|
||||
# Plantillas de conversión
|
||||
MIGRATION_TEMPLATES = {
|
||||
'pass_fail': {
|
||||
'new_type': 'boolean',
|
||||
'config': {
|
||||
'type': 'boolean',
|
||||
'choices': [
|
||||
{
|
||||
'value': 'pass',
|
||||
'label': 'Pasa',
|
||||
'points': None, # Se asignará dinámicamente
|
||||
'status': 'ok'
|
||||
},
|
||||
{
|
||||
'value': 'fail',
|
||||
'label': 'Falla',
|
||||
'points': 0,
|
||||
'status': 'critical'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
'good_bad': {
|
||||
'new_type': 'boolean',
|
||||
'config': {
|
||||
'type': 'boolean',
|
||||
'choices': [
|
||||
{
|
||||
'value': 'good',
|
||||
'label': 'Bueno',
|
||||
'points': None, # Se asignará dinámicamente
|
||||
'status': 'ok'
|
||||
},
|
||||
{
|
||||
'value': 'bad',
|
||||
'label': 'Malo',
|
||||
'points': 0,
|
||||
'status': 'critical'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
'good_bad_regular': {
|
||||
'new_type': 'single_choice',
|
||||
'config': {
|
||||
'type': 'single_choice',
|
||||
'choices': [
|
||||
{
|
||||
'value': 'good',
|
||||
'label': 'Bueno',
|
||||
'points': None, # Se asignará dinámicamente
|
||||
'status': 'ok'
|
||||
},
|
||||
{
|
||||
'value': 'regular',
|
||||
'label': 'Regular',
|
||||
'points': None, # Se calculará como points/2
|
||||
'status': 'warning'
|
||||
},
|
||||
{
|
||||
'value': 'bad',
|
||||
'label': 'Malo',
|
||||
'points': 0,
|
||||
'status': 'critical'
|
||||
}
|
||||
],
|
||||
'allow_other': False
|
||||
}
|
||||
},
|
||||
'text': {
|
||||
'new_type': 'text',
|
||||
'config': {
|
||||
'type': 'text',
|
||||
'multiline': True,
|
||||
'max_length': 500
|
||||
}
|
||||
},
|
||||
'number': {
|
||||
'new_type': 'number',
|
||||
'config': {
|
||||
'type': 'number',
|
||||
'min': 0,
|
||||
'max': 100,
|
||||
'unit': ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class QuestionMigrator:
|
||||
def __init__(self):
|
||||
self.conn = None
|
||||
self.cursor = None
|
||||
self.stats = {
|
||||
'total_questions': 0,
|
||||
'migrated': 0,
|
||||
'skipped': 0,
|
||||
'errors': 0,
|
||||
'by_type': {}
|
||||
}
|
||||
|
||||
def connect(self):
|
||||
"""Conectar a la base de datos"""
|
||||
try:
|
||||
print(f"🔌 Conectando a {DB_CONFIG['host']}...")
|
||||
self.conn = psycopg2.connect(**DB_CONFIG)
|
||||
self.cursor = self.conn.cursor()
|
||||
print("✅ Conexión exitosa\n")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Error de conexión: {e}")
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Cerrar conexión"""
|
||||
if self.cursor:
|
||||
self.cursor.close()
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
print("\n🔌 Conexión cerrada")
|
||||
|
||||
def backup_questions_table(self):
|
||||
"""Crear backup de la tabla questions"""
|
||||
try:
|
||||
print("💾 Creando backup de la tabla questions...")
|
||||
|
||||
# Crear tabla de backup con timestamp
|
||||
backup_table = f"questions_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
self.cursor.execute(f"""
|
||||
CREATE TABLE {backup_table} AS
|
||||
SELECT * FROM questions;
|
||||
""")
|
||||
|
||||
self.cursor.execute(f"SELECT COUNT(*) FROM {backup_table}")
|
||||
count = self.cursor.fetchone()[0]
|
||||
|
||||
self.conn.commit()
|
||||
print(f"✅ Backup creado: {backup_table} ({count} registros)\n")
|
||||
return backup_table
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creando backup: {e}")
|
||||
return None
|
||||
|
||||
def get_questions_to_migrate(self) -> List[Dict[str, Any]]:
|
||||
"""Obtener todas las preguntas que necesitan migración"""
|
||||
try:
|
||||
print("📊 Obteniendo preguntas para migrar...")
|
||||
|
||||
self.cursor.execute("""
|
||||
SELECT
|
||||
id,
|
||||
checklist_id,
|
||||
section,
|
||||
text,
|
||||
type,
|
||||
points,
|
||||
options,
|
||||
allow_photos,
|
||||
max_photos,
|
||||
requires_comment_on_fail,
|
||||
send_notification,
|
||||
parent_question_id,
|
||||
show_if_answer,
|
||||
ai_prompt
|
||||
FROM questions
|
||||
ORDER BY id
|
||||
""")
|
||||
|
||||
questions = []
|
||||
for row in self.cursor.fetchall():
|
||||
questions.append({
|
||||
'id': row[0],
|
||||
'checklist_id': row[1],
|
||||
'section': row[2],
|
||||
'text': row[3],
|
||||
'type': row[4],
|
||||
'points': row[5],
|
||||
'options': row[6],
|
||||
'allow_photos': row[7],
|
||||
'max_photos': row[8],
|
||||
'requires_comment_on_fail': row[9],
|
||||
'send_notification': row[10],
|
||||
'parent_question_id': row[11],
|
||||
'show_if_answer': row[12],
|
||||
'ai_prompt': row[13]
|
||||
})
|
||||
|
||||
self.stats['total_questions'] = len(questions)
|
||||
print(f"✅ Se encontraron {len(questions)} preguntas\n")
|
||||
|
||||
return questions
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error obteniendo preguntas: {e}")
|
||||
return []
|
||||
|
||||
def migrate_question(self, question: Dict[str, Any]) -> bool:
|
||||
"""Migrar una pregunta al nuevo formato"""
|
||||
try:
|
||||
old_type = question['type']
|
||||
|
||||
# Si ya está en el nuevo formato, saltar
|
||||
if old_type in ['boolean', 'single_choice', 'multiple_choice', 'scale', 'text', 'number', 'date', 'time']:
|
||||
if question['options'] and isinstance(question['options'], dict) and 'type' in question['options']:
|
||||
self.stats['skipped'] += 1
|
||||
return True
|
||||
|
||||
# Obtener template de migración
|
||||
if old_type not in MIGRATION_TEMPLATES:
|
||||
print(f" ⚠️ Tipo desconocido '{old_type}' para pregunta #{question['id']}")
|
||||
self.stats['skipped'] += 1
|
||||
return True
|
||||
|
||||
template = MIGRATION_TEMPLATES[old_type]
|
||||
new_type = template['new_type']
|
||||
new_config = json.loads(json.dumps(template['config'])) # Deep copy
|
||||
|
||||
# Asignar puntos dinámicamente
|
||||
if 'choices' in new_config:
|
||||
for choice in new_config['choices']:
|
||||
if choice['points'] is None:
|
||||
choice['points'] = question['points']
|
||||
|
||||
# Para good_bad_regular, calcular puntos intermedios
|
||||
if old_type == 'good_bad_regular':
|
||||
new_config['choices'][1]['points'] = max(1, question['points'] // 2)
|
||||
|
||||
# Actualizar la pregunta
|
||||
self.cursor.execute("""
|
||||
UPDATE questions
|
||||
SET
|
||||
type = %s,
|
||||
options = %s
|
||||
WHERE id = %s
|
||||
""", (new_type, json.dumps(new_config), question['id']))
|
||||
|
||||
# Actualizar estadísticas
|
||||
self.stats['migrated'] += 1
|
||||
if old_type not in self.stats['by_type']:
|
||||
self.stats['by_type'][old_type] = 0
|
||||
self.stats['by_type'][old_type] += 1
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error migrando pregunta #{question['id']}: {e}")
|
||||
self.stats['errors'] += 1
|
||||
return False
|
||||
|
||||
def verify_migration(self):
|
||||
"""Verificar que la migración fue exitosa"""
|
||||
print("\n🔍 Verificando migración...")
|
||||
|
||||
try:
|
||||
# Contar por tipo nuevo
|
||||
self.cursor.execute("""
|
||||
SELECT
|
||||
type,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN options IS NOT NULL THEN 1 END) as with_config
|
||||
FROM questions
|
||||
GROUP BY type
|
||||
ORDER BY type
|
||||
""")
|
||||
|
||||
print("\n📊 Distribución de preguntas migradas:")
|
||||
print("-" * 60)
|
||||
for row in self.cursor.fetchall():
|
||||
tipo, total, with_config = row
|
||||
print(f" {tipo:20} | Total: {total:4} | Con config: {with_config:4}")
|
||||
print("-" * 60)
|
||||
|
||||
# Verificar que todas las boolean tengan 2 choices
|
||||
# Usar CAST para compatibilidad con JSON y JSONB
|
||||
self.cursor.execute("""
|
||||
SELECT id, text, options
|
||||
FROM questions
|
||||
WHERE type = 'boolean'
|
||||
AND (
|
||||
options IS NULL
|
||||
OR json_array_length((options::json)->'choices') != 2
|
||||
)
|
||||
LIMIT 5
|
||||
""")
|
||||
|
||||
invalid = self.cursor.fetchall()
|
||||
if invalid:
|
||||
print(f"\n⚠️ Advertencia: {len(invalid)} preguntas boolean con configuración inválida:")
|
||||
for q_id, text, opts in invalid:
|
||||
print(f" - #{q_id}: {text[:50]}...")
|
||||
else:
|
||||
print("\n✅ Todas las preguntas boolean tienen configuración válida")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error en verificación: {e}")
|
||||
return False
|
||||
|
||||
def print_statistics(self):
|
||||
"""Imprimir estadísticas de la migración"""
|
||||
print("\n" + "=" * 60)
|
||||
print("📈 ESTADÍSTICAS DE MIGRACIÓN")
|
||||
print("=" * 60)
|
||||
print(f"Total de preguntas: {self.stats['total_questions']}")
|
||||
print(f"Migradas exitosamente: {self.stats['migrated']}")
|
||||
print(f"Omitidas: {self.stats['skipped']}")
|
||||
print(f"Errores: {self.stats['errors']}")
|
||||
print("\nPor tipo original:")
|
||||
for tipo, count in self.stats['by_type'].items():
|
||||
print(f" - {tipo:20}: {count:4} preguntas")
|
||||
print("=" * 60)
|
||||
|
||||
def run(self, dry_run=False):
|
||||
"""Ejecutar la migración completa"""
|
||||
print("=" * 60)
|
||||
print("🚀 MIGRACIÓN DE TIPOS DE PREGUNTAS")
|
||||
print("=" * 60)
|
||||
print(f"Modo: {'🔍 DRY RUN (sin cambios)' if dry_run else '✍️ MIGRACIÓN REAL'}")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
if not self.connect():
|
||||
return False
|
||||
|
||||
try:
|
||||
# Crear backup
|
||||
if not dry_run:
|
||||
backup_table = self.backup_questions_table()
|
||||
if not backup_table:
|
||||
print("⚠️ No se pudo crear backup. ¿Continuar de todos modos? (y/n): ", end='')
|
||||
if input().lower() != 'y':
|
||||
return False
|
||||
|
||||
# Obtener preguntas
|
||||
questions = self.get_questions_to_migrate()
|
||||
if not questions:
|
||||
print("❌ No se encontraron preguntas para migrar")
|
||||
return False
|
||||
|
||||
# Migrar cada pregunta
|
||||
print("🔄 Migrando preguntas...\n")
|
||||
for i, question in enumerate(questions, 1):
|
||||
old_type = question['type']
|
||||
|
||||
if self.migrate_question(question):
|
||||
if i % 10 == 0:
|
||||
print(f" Progreso: {i}/{len(questions)} preguntas procesadas...")
|
||||
|
||||
# Commit o rollback según modo
|
||||
if dry_run:
|
||||
self.conn.rollback()
|
||||
print("\n🔍 DRY RUN completado - Cambios revertidos")
|
||||
else:
|
||||
self.conn.commit()
|
||||
print("\n✅ Migración completada - Cambios guardados")
|
||||
|
||||
# Verificar migración
|
||||
self.verify_migration()
|
||||
|
||||
# Mostrar estadísticas
|
||||
self.print_statistics()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error durante la migración: {e}")
|
||||
if self.conn:
|
||||
self.conn.rollback()
|
||||
print("🔄 Cambios revertidos")
|
||||
return False
|
||||
|
||||
finally:
|
||||
self.disconnect()
|
||||
|
||||
|
||||
def main():
|
||||
"""Función principal"""
|
||||
print("\n" + "=" * 60)
|
||||
print(" MIGRACIÓN DE TIPOS DE PREGUNTAS - Sistema Configurable")
|
||||
print(" Base de datos: syntria_db @ portianerp.rshtech.com.py")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
print("Este script migrará las preguntas existentes al nuevo formato:")
|
||||
print(" • pass_fail → boolean (Pasa/Falla)")
|
||||
print(" • good_bad → boolean (Bueno/Malo)")
|
||||
print(" • good_bad_regular → single_choice (Bueno/Regular/Malo)")
|
||||
print(" • text → text (con configuración)")
|
||||
print(" • number → number (con configuración)\n")
|
||||
|
||||
# Preguntar modo
|
||||
print("Seleccione el modo de ejecución:")
|
||||
print(" 1. DRY RUN - Ver cambios sin aplicarlos (recomendado primero)")
|
||||
print(" 2. MIGRACIÓN REAL - Aplicar cambios permanentes")
|
||||
print("\nOpción (1/2): ", end='')
|
||||
|
||||
try:
|
||||
option = input().strip()
|
||||
dry_run = (option != '2')
|
||||
|
||||
if not dry_run:
|
||||
print("\n⚠️ ADVERTENCIA: Esto modificará la base de datos de producción.")
|
||||
print("Se creará un backup automático antes de continuar.")
|
||||
print("\n¿Continuar? (escriba 'SI' para confirmar): ", end='')
|
||||
confirm = input().strip()
|
||||
|
||||
if confirm != 'SI':
|
||||
print("\n❌ Migración cancelada por el usuario")
|
||||
return
|
||||
|
||||
# Ejecutar migración
|
||||
migrator = QuestionMigrator()
|
||||
success = migrator.run(dry_run=dry_run)
|
||||
|
||||
if success:
|
||||
if not dry_run:
|
||||
print("\n✅ Migración completada exitosamente!")
|
||||
print("\nPróximos pasos:")
|
||||
print(" 1. Reiniciar el servidor backend")
|
||||
print(" 2. Probar crear nuevas preguntas con el editor visual")
|
||||
print(" 3. Verificar que las inspecciones existentes sigan funcionando")
|
||||
else:
|
||||
print("\n✅ DRY RUN completado!")
|
||||
print("\nPara aplicar los cambios, ejecute nuevamente y seleccione opción 2")
|
||||
else:
|
||||
print("\n❌ La migración falló. Revise los errores arriba.")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ Migración cancelada por el usuario")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error inesperado: {e}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user