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
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use App\Models\Evaluacion;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class EvaluacionController extends Controller
{
public function index()
{
$evaluaciones = Evaluacion::with('tipo')
->withCount(['preguntas', 'submissions'])
->latest()
->get();
return view('evaluacion.index', compact('evaluaciones'));
}
public function create()
{
return view('evaluacion.create');
}
public function store(Request $request)
{
// Manejado por Livewire EvaluacionBuilder
}
public function show(Evaluacion $evaluacion)
{
$evaluacion->load(['tipo', 'preguntas.tipo', 'preguntas.opciones', 'planteles', 'niveles']);
return view('evaluacion.show', compact('evaluacion'));
}
public function edit(Evaluacion $evaluacion)
{
$evaluacion->load(['preguntas.opciones', 'planteles', 'niveles']);
return view('evaluacion.create', compact('evaluacion'));
}
public function update(Request $request, Evaluacion $evaluacion)
{
// Manejado por Livewire EvaluacionBuilder
}
public function destroy(Evaluacion $evaluacion)
{
$evaluacion->delete();
return redirect()->route('evaluacion.index')->with('info', 'Evaluación eliminada.');
}
public function toggleStatus(Request $request, Evaluacion $evaluacion)
{
$request->validate(['status' => 'required|in:borrador,activa,cerrada']);
$evaluacion->update(['status' => $request->status]);
return back()->with('info', 'Estado actualizado.');
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class EvaluacionResponderController extends Controller
{
//
}
+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');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Evaluacion extends Model
{
protected $table = 'evaluaciones';
protected $guarded = [];
protected $casts = [
'anonima' => 'boolean',
'fecha_inicio' => 'date',
'fecha_fin' => 'date',
];
public function tipo(): BelongsTo
{
return $this->belongsTo(EvaluacionTipo::class, 'evaluacion_tipo_id');
}
public function preguntas(): HasMany
{
return $this->hasMany(Pregunta::class)->orderBy('orden');
}
public function planteles(): BelongsToMany
{
return $this->belongsToMany(Plantel::class, 'evaluacion_plantel');
}
public function niveles(): BelongsToMany
{
return $this->belongsToMany(Nivel::class, 'evaluacion_nivel');
}
public function submissions(): HasMany
{
return $this->hasMany(EvaluacionSubmission::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EvaluacionSubmission extends Model
{
protected $guarded = [];
public function evaluacion(): BelongsTo
{
return $this->belongsTo(Evaluacion::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function respuestas(): HasMany
{
return $this->hasMany(Respuesta::class, 'submission_id');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EvaluacionTipo extends Model
{
public $timestamps = false;
protected $guarded = [];
public function evaluaciones(): HasMany
{
return $this->hasMany(Evaluacion::class);
}
}
+5
View File
@@ -42,4 +42,9 @@ class Nivel extends Model
'documento_tipo_id'
);
}
public function evaluaciones() : BelongsToMany
{
return $this->belongsToMany(Evaluacion::class, 'evaluacion_nivel');
}
}
+5
View File
@@ -35,4 +35,9 @@ class Plantel extends Model
{
return $this->belongsToMany(Turno::class);
}
public function evaluaciones() : BelongsToMany
{
return $this->belongsToMany(Evaluacion::class, 'evaluacion_plantel');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Pregunta extends Model
{
protected $guarded = [];
public function evaluacion(): BelongsTo
{
return $this->belongsTo(Evaluacion::class);
}
public function tipo(): BelongsTo
{
return $this->belongsTo(PreguntaTipo::class, 'pregunta_tipo_id');
}
public function opciones(): HasMany
{
return $this->hasMany(PreguntaOpcion::class)->orderBy('orden');
}
public function respuestas(): HasMany
{
return $this->hasMany(Respuesta::class);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PreguntaOpcion extends Model
{
protected $table = 'pregunta_opciones';
public $timestamps = false;
protected $guarded = [];
public function pregunta(): BelongsTo
{
return $this->belongsTo(Pregunta::class);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PreguntaTipo extends Model
{
public $timestamps = false;
protected $guarded = [];
public function preguntas(): HasMany
{
return $this->hasMany(Pregunta::class);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Respuesta extends Model
{
protected $guarded = [];
public function submission(): BelongsTo
{
return $this->belongsTo(EvaluacionSubmission::class);
}
public function pregunta(): BelongsTo
{
return $this->belongsTo(Pregunta::class);
}
public function opcion(): BelongsTo
{
return $this->belongsTo(PreguntaOpcion::class, 'opcion_id');
}
}