feat: módulo de evaluaciones para admin con constructor dinámico

- Modelos completos: Evaluacion, EvaluacionTipo, Pregunta, PreguntaTipo,
  PreguntaOpcion, EvaluacionSubmission, Respuesta (con $table explícito
  para nombres en español que Laravel pluraliza incorrectamente)
- Relaciones evaluaciones() añadidas a Plantel y Nivel
- EvaluacionBuilder (Livewire): constructor interactivo con preguntas
  de tipo texto libre, opción múltiple, escala 1-5/1-10 y sí/no;
  reorden con flechas, vista previa por tipo, selección de planteles
  y niveles, configuración de estado, fechas y flag de anónima
- EvaluacionController: CRUD completo + toggleStatus
- Vistas: index, create/edit (con Livewire builder), show con detalle
  de preguntas y cambio de estado
- Rutas resource /evaluacion + PATCH /evaluacion/{id}/status
- Migraciones: evaluaciones, preguntas, pregunta_opciones,
  evaluacion_submissions, respuestas, evaluacion_plantel, evaluacion_nivel
- Menú: enlace Evaluaciones en panel de Administrador

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 18:09:48 -06:00
parent f81237b23c
commit 2af36ea272
27 changed files with 1471 additions and 21 deletions
+213
View File
@@ -0,0 +1,213 @@
<?php
namespace App\Livewire;
use App\Models\Evaluacion;
use App\Models\EvaluacionTipo;
use App\Models\Nivel;
use App\Models\Plantel;
use App\Models\Pregunta;
use App\Models\PreguntaOpcion;
use App\Models\PreguntaTipo;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class EvaluacionBuilder extends Component
{
public ?int $evaluacionId = null;
public string $titulo = '';
public string $descripcion = '';
public string $evaluacionTipoId = '';
public string $status = 'borrador';
public string $fechaInicio = '';
public string $fechaFin = '';
public bool $anonima = false;
public array $selectedPlanteles = [];
public array $selectedNiveles = [];
public array $preguntas = [];
// Catálogos cargados en mount (solo lectura en la vista)
public array $tipos = [];
public array $preguntaTipos = [];
public array $planteles = [];
public array $niveles = [];
public function mount($evaluacion = null): void
{
$this->tipos = EvaluacionTipo::all(['id', 'nombre', 'slug'])->toArray();
$this->preguntaTipos = PreguntaTipo::all(['id', 'nombre', 'slug'])->toArray();
$this->planteles = Plantel::orderBy('name')->get(['id', 'name'])->toArray();
$this->niveles = Nivel::orderBy('name')->get(['id', 'name'])->toArray();
if ($evaluacion instanceof Evaluacion && $evaluacion->exists) {
$this->evaluacionId = $evaluacion->id;
$this->titulo = $evaluacion->titulo;
$this->descripcion = $evaluacion->descripcion ?? '';
$this->evaluacionTipoId = (string) $evaluacion->evaluacion_tipo_id;
$this->status = $evaluacion->status;
$this->fechaInicio = $evaluacion->fecha_inicio?->format('Y-m-d') ?? '';
$this->fechaFin = $evaluacion->fecha_fin?->format('Y-m-d') ?? '';
$this->anonima = (bool) $evaluacion->anonima;
$this->selectedPlanteles = $evaluacion->planteles->pluck('id')->map(fn($v) => (string) $v)->toArray();
$this->selectedNiveles = $evaluacion->niveles->pluck('id')->map(fn($v) => (string) $v)->toArray();
$this->preguntas = $evaluacion->preguntas->map(fn($p) => [
'id' => $p->id,
'texto' => $p->texto,
'pregunta_tipo_id' => (string) $p->pregunta_tipo_id,
'requerida' => (bool) $p->requerida,
'opciones' => $p->opciones->map(fn($o) => [
'id' => $o->id,
'texto' => $o->texto,
])->toArray(),
])->toArray();
}
}
public function addPregunta(): void
{
$defaultTipo = $this->preguntaTipos[0]['id'] ?? '';
$this->preguntas[] = [
'id' => null,
'texto' => '',
'pregunta_tipo_id' => (string) $defaultTipo,
'requerida' => true,
'opciones' => [],
];
}
public function removePregunta(int $index): void
{
array_splice($this->preguntas, $index, 1);
$this->preguntas = array_values($this->preguntas);
}
public function movePregunta(int $index, string $direction): void
{
$swap = $direction === 'up' ? $index - 1 : $index + 1;
if ($swap < 0 || $swap >= count($this->preguntas)) {
return;
}
[$this->preguntas[$index], $this->preguntas[$swap]] = [$this->preguntas[$swap], $this->preguntas[$index]];
}
public function addOpcion(int $preguntaIndex): void
{
$this->preguntas[$preguntaIndex]['opciones'][] = ['id' => null, 'texto' => ''];
}
public function removeOpcion(int $preguntaIndex, int $opcionIndex): void
{
array_splice($this->preguntas[$preguntaIndex]['opciones'], $opcionIndex, 1);
$this->preguntas[$preguntaIndex]['opciones'] = array_values($this->preguntas[$preguntaIndex]['opciones']);
}
public function guardar(): void
{
$this->validate([
'titulo' => 'required|string|max:255',
'descripcion' => 'nullable|string',
'evaluacionTipoId' => 'required|exists:evaluacion_tipos,id',
'status' => 'required|in:borrador,activa,cerrada',
'fechaInicio' => 'nullable|date',
'fechaFin' => 'nullable|date',
'selectedPlanteles' => 'array',
'selectedNiveles' => 'array',
'preguntas' => 'required|array|min:1',
'preguntas.*.texto' => 'required|string|max:1000',
'preguntas.*.pregunta_tipo_id' => 'required|exists:pregunta_tipos,id',
'preguntas.*.opciones.*.texto' => 'required|string|max:500',
], [
'titulo.required' => 'El título es obligatorio.',
'evaluacionTipoId.required' => 'Selecciona el tipo de evaluación.',
'preguntas.required' => 'Agrega al menos una pregunta.',
'preguntas.min' => 'Agrega al menos una pregunta.',
'preguntas.*.texto.required' => 'El texto de la pregunta es obligatorio.',
'preguntas.*.pregunta_tipo_id.required' => 'Selecciona el tipo de respuesta.',
'preguntas.*.opciones.*.texto.required' => 'El texto de la opción es obligatorio.',
]);
DB::transaction(function () {
$data = [
'titulo' => $this->titulo,
'descripcion' => $this->descripcion ?: null,
'evaluacion_tipo_id' => $this->evaluacionTipoId,
'status' => $this->status,
'fecha_inicio' => $this->fechaInicio ?: null,
'fecha_fin' => $this->fechaFin ?: null,
'anonima' => $this->anonima,
];
if ($this->evaluacionId) {
$evaluacion = Evaluacion::findOrFail($this->evaluacionId);
$evaluacion->update($data);
} else {
$evaluacion = Evaluacion::create($data);
$this->evaluacionId = $evaluacion->id;
}
$evaluacion->planteles()->sync($this->selectedPlanteles);
$evaluacion->niveles()->sync($this->selectedNiveles);
// Eliminar preguntas removidas
$keptIds = collect($this->preguntas)->pluck('id')->filter()->values()->toArray();
$evaluacion->preguntas()->whereNotIn('id', $keptIds)->delete();
foreach ($this->preguntas as $orden => $pData) {
if (!empty($pData['id'])) {
$pregunta = Pregunta::findOrFail($pData['id']);
$pregunta->update([
'texto' => $pData['texto'],
'pregunta_tipo_id' => $pData['pregunta_tipo_id'],
'requerida' => $pData['requerida'] ?? true,
'orden' => $orden,
]);
} else {
$pregunta = $evaluacion->preguntas()->create([
'texto' => $pData['texto'],
'pregunta_tipo_id' => $pData['pregunta_tipo_id'],
'requerida' => $pData['requerida'] ?? true,
'orden' => $orden,
]);
}
$tipoSlug = collect($this->preguntaTipos)
->firstWhere('id', (int) $pData['pregunta_tipo_id'])['slug'] ?? '';
if ($tipoSlug === 'opcion_multiple') {
$keptOpcionIds = collect($pData['opciones'] ?? [])
->pluck('id')->filter()->values()->toArray();
$pregunta->opciones()->whereNotIn('id', $keptOpcionIds)->delete();
foreach ($pData['opciones'] ?? [] as $opOrden => $opData) {
if (!empty($opData['id'])) {
PreguntaOpcion::findOrFail($opData['id'])->update([
'texto' => $opData['texto'],
'orden' => $opOrden,
]);
} else {
$pregunta->opciones()->create([
'texto' => $opData['texto'],
'orden' => $opOrden,
]);
}
}
} else {
$pregunta->opciones()->delete();
}
}
});
session()->flash('info', 'Evaluación guardada correctamente.');
$this->redirect(route('evaluacion.index'));
}
public function render()
{
return view('livewire.evaluacion-builder');
}
}